20. Test-driven Development

Wyzwania:

  • poznasz koncept TDD (Test-Driven Development),
  • dowiesz się, jak pisać dobre testy przed implementacją samej funkcjonalności,
  • wykorzystasz nową wiedzę w praktyce.

20.1. TDD – na czym to polega?

Wiemy już, czym są testy i dlaczego warto z nich korzystać. Poznaliśmy również ich trzy rodzaje, przy czym skupiliśmy się głównie na testach jednostkowych i używaliśmy ich w praktyce. Stosowaliśmy jednak metodę, która nie zawsze jest idealna – wszystkie testy pisaliśmy dopiero po implementacji jednej lub kilku funkcjonalności. Możesz pomyśleć, że to logiczne, bo testy były naszym buforem bezpieczeństwie, który miał gwarantować, że nasz kod nie sprawi przykrych niespodzianek, gdy pokażemy go klientowi. To podejście ma jednak kilka wad.

Wady pisania testów po implementacji

Po pierwsze, pisanie testów do gotowego kodu nie jest łatwe. Gdy kod jest już kompletny, to często jego ilość i poziom skomplikowania są dość duże. Przez to ciężko napisać nam do niego poprawne testy: takie, które sprawdzą wszystkie scenariusze. Im bardziej skomplikowany kod, tym trudniejsze zadanie przed nami.

Po drugie, im dłużej zwlekamy z testami, tym więcej pracy przed nami. Wyobraź sobie, że napisaliśmy np. pięć dużych komponentów i dopiero przed oddaniem aplikacji do klienta, chcemy dodać jeszcze testy. Bardzo możliwe, że nad niektórymi komponentami pracowaliśmy tydzień, dwa tygodnie temu. Przez to często musimy spędzić najpierw trochę czasu nad zrozumieniem kodu, aby w ogóle połapać się, co chcemy przetestować. Powoduje to też, że testy zabierają nam bardzo dużo czasu i robimy je bardzo niechętnie.

Po trzecie, w przypadku gotowego kodu jesteśmy podatni na pisanie testów pod wynik. Wynika to też trochę z punktu drugiego. Nie chcemy tracić czasu, piszemy testy szybko, "na kolanie", byle móc pokazać projekt klientowi. Często, zamiast tworzyć obiektywne testy, sprawdzamy tylko najczęstsze przypadki. W ten sposób utwierdzamy się w tym, że nasz kod jest dobry, choć to przekonanie może być złudne.

Po czwarte, o niektórych testach można zwyczajnie łatwo zapomnieć. Jeśli piszemy testy na końcu pracy, to mamy bardzo dużą szansę na pominięcie niektórych z nich. Mowa tu o komponentach, funkcjonalnościach, ale też pojedynczych przypadkach. Wyobraź sobie, że piszesz np. funkcję cutText, która w założeniu miała przycinać tekst do podanej liczby znaków. W przypadku podania liczby, zamiast tekstu funkcja ma zwrócić null. Jeśli przy pisaniu tej funkcji zapomnieliśmy o sprawdzeniu, czy podana wartość jest tekstem, to jest duża szansa, że zwyczajnie zapomnimy również o napisaniu potrzebnego testu.

Po piąte, kod może być mniej testowalny. Po poprzednim module wiesz już, że czasami kod testuje się łatwiej, a czasami trudniej. Jeśli nie piszemy go od początku z myślą, że ma być łatwy do testowania, to prawdopodobnie dodajemy sobie pracy na później. A mówiliśmy już, że przez czasochłonność testów pisanych po fakcie często nie chce nam się ich pisać, albo piszemy je "na kolanie", żeby tylko potwierdziły, że nasz kod już w aktualnej formie jest idealny.

Im rzadziej piszemy testy, czym więcej kodu zostawiamy sobie do testowania na później, tym więcej z powyższych wad się ujawni. Tak czy inaczej jednak, widzisz pewnie, że podejście "najpierw funkcjonalności, potem testy", nie jest do końca idealne. W niniejszym module pokażemy Ci jednak trochę inny sposób, który rozwiąże wszystkie powyższe problemy.

Odpowiedzią jest TDD

W tym module poznamy często stosowane podejście TDDTest-Driven Ddevelopment. Za jego użyciem stoją dwie idee. Pierwsza mówi, że testy powinny być pisane przed samą funkcjonalnością. Oznacza to, że najpierw piszemy test, który określa, jakie zadania powinna spełnić nowa funkcjonalność, a dopiero potem ją implementujemy. Druga idea mówi, że powinniśmy testować kod na bieżąco, małymi fragmentami: nie robimy najpierw pięciu testów, by potem wszystko na raz zaimplementować. Idziemy krok po kroku: robimy test, a następnie implementujemy funkcjonalność. Później wykonujemy kolejny test, dodajemy kolejną funkcjonalność itd.

Całą technikę możemy rozpisać jako następujący algorytm:

  1. Piszemy nowy test.
  2. Uruchamiamy go (w tym momencie powinien zwracać wynik negatywny).
  3. Następnie implementujemy funkcjonalność w taki sposób, aby przeszła test.
  4. Refaktorujemy (przerabiamy) kod, tak aby spełnił nasze oczekiwania.

Dla każdej nowej funkcjonalności powtarzamy ten cykl od początku.

Zalety TDD

Cały kod jest testowany

Jeśli przed każdą implementacją jakiejkolwiek funkcjonalności będziemy startować od napisania testu, to mamy pewność, że niczego nie pominiemy. Każdy kod, który pojawi się w aplikacji, na pewno będzie miał odpowiadający mu test. Nie ma takiej opcji, że z powodu nawału pracy o czymś zapomnimy i jakiś kod będzie niesprawdzany.

Bugi są znajdowane na bieżąco

Wyobraź sobie, że nie stosujesz podejścia TDD i piszesz testy, kiedy dużo pracy jest już za Tobą. Nagle może okazać się, że poprawnie napisane przez Ciebie testy wykryją mnóstwo bugów. Zmusiłoby to Cię do wielu napraw, a istnieje duża szansa, że naprawienie jednego błędu spowoduje problem w innym miejscu. W przypadku TDD o każdym błędzie będziemy wiedzieli na bieżąco, gdy tylko wystąpi.

Testowanie nie "męczy"

Dzięki temu, że piszemy małe testy na bieżąco, nie dochodzi do sytuacji, w której czujemy się przytłoczeni ich liczbą. Nie musimy rozdzielać czasu pracy na kodowanie, a potem żmudne pisanie testów. Traktujemy je bardziej jako część całego procesu.

Kod jest bardziej przemyślany

Testy pełnią funkcję zapisanych wymagań funkcjonalności, którą za chwilę będziemy tworzyć. Dzięki temu naturalnie będziemy głębiej zastanawiać się, jak dokładnie powinna ona wyglądać i działać. Dzięki temu, gdy przechodzimy już do kodowania, wiemy dobrze, do czego dążymy. Nie poruszamy się "na ślepo", tylko dokładnie wiemy, jak nasza funkcja powinna być zbudowana.

Refactoring bez strachu

Testowanie na bieżąco to też duża dawka pewności dla programistów. Nie musimy obawiać się refactoringu, czyli przerabiania kodu np. kiedy trzeba zmienić sposób działania jakiegoś komponentu. Nie musimy martwić się, że gdzieś w dotychczasowym kodzie znajduje się błąd – ewentualne błędy byłyby bowiem szybko wykryte na etapie bardzo małego fragmentu, poszczególnej funkcjonalności. Co więcej, jeśli np. rozszerzamy komponent o dodatkową funkcjonalność, nie musimy obawiać się, że zepsujemy coś, co do tej pory działało – jeśli tak się stanie, od razu dowiemy się o tym z testów.

Czy to podejście dla wszystkich?

Widzimy sporo zalet takiego rozwiązania, ale czy ma ono również jakieś wady?

Na pewno jedną rzeczą, na którą musimy zwrócić uwagę, to problem z odpowiednim zaplanowaniem funkcjonalności na etapie pisania testów. W końcu musimy ustalić, co testujemy, zanim napiszemy jakikolwiek kod. Niektórym przychodzi to łatwo, niektórym trudniej. Początkujący mogą mieć czasami problem z odpowiednim przeanalizowaniem, jak docelowo powinna działać dana funkcjonalność i co dokładnie powinno być testowane. Przez to proces ten może na starcie zajmować trochę czasu. Potem jednak, po wdrożeniu się w to podejście, będzie Ci szło coraz łatwiej.

Drugą sprawą jest odpowiedzialność. Jeśli chcemy, żeby TDD faktycznie przyniosło nam pozytywne efekty, to cały team musi zgodnie podążać za jego zasadami. Każdy musi się pilnować i rzetelnie podchodzić do pisania testów. Jeśli bowiem nawet jeden z developerów się wyłamie i kilka razy podąży inną drogą, to całą "pewność", którą daje nam TDD, będzie tylko iluzoryczna.

20.2. TDD w praktyce

Aby lepiej zrozumieć TDD, przejdziemy do praktycznego przykładu. W tym celu rozwiniemy aplikację agencji turystycznej, nad którą pracowaliśmy w poprzednich modułach.

Funkcjonalnością, którą będziemy starać się zaimplementować w tym module, będzie komponent odliczający czas do promocji "Happy Hour", która jest aktywna codziennie od 12:00 do 13:00.

Będzie to wyglądało następująco:

image

Pokażemy Ci, jak zabrać się za przeanalizowanie tej funkcjonalności, a następnie opiszemy testy do każdego z wymagań. Na końcu tego submodułu Twoim zadaniem będzie implementacja tego komponentu w taki sposób, aby spełniła wymagania.

Zanim zaczniemy, warto wspomnieć o okolicznościach tego zlecenia. Możemy założyć, że klient chciałby zobaczyć, jak taki komponent by działał i skonsultować to ze swoimi współpracownikami. Na tym etapie nie tworzymy docelowego rozwiązania, które będzie funkcjonowało na stronie, tylko tzw. proof of concept. Chcemy jednak od razu pisać kod w taki sposób, aby jak największą jego część można było później wykorzystać w docelowym rozwiązaniu.

Założenia

Aby napisać testy, nie wiedząc, jak będzie dokładnie wyglądała końcowa implementacja, musimy mieć przynajmniej ogólne pojęcie, jak dana funkcjonalność ma działać. Zaczniemy więc od spokojnego przeanalizowania założeń – dzięki temu będziemy mogli z grubsza określić wymagania. To z kolei pozwoli nam dobrze przygotować testy, które wezmą pod uwagę odpowiednie przypadki.

Zadaniem komponentu HappyHourAd będzie wyświetlenie nagłówka "Happy Hour" oraz czasu do następnej promocji. Na razie będziemy wyświetlać czas w sekundach, ale może w przyszłości zmienimy sposób wyświetlania. ;)

Jak już wspomnieliśmy, promocja odbywa się codziennie o 12:00 (w południe), więc musimy wyświetlać czas pozostały do tej godziny. Wyjątkiem będzie czas trwania promocji – w godzinach od 12:00 do 13:00 zamiast odliczania, komponent powinien pokazywać komunikat "It's your time! Take advantage of Happy Hour! All offers 20% off!". Warto pamiętać, że ten komunikat ma wyświetlać się zarówno kiedy strona zostanie otworzona między 12:00, a 13:00, jak i w sytuacji, gdy strona była otwarta wcześniej i odliczanie doszło do zera.

Uwaga! "Happy Hour" ma się odbywać o 12:00 czasu UTC, a nie lokalnego. W końcu różni klienci mogą pochodzić z różnych stref czasowych. Lepiej więc bazować na czasie, który obowiązuje w siedzibie firmy klienta.

Na razie to koniec wymagań – piszemy tylko proof of concept, więc nie musimy się przejmować niektórymi aspektami jego działania:

  • jeśli strona jest otwarta w momencie, gdy wybije godzina 13:00, nie musimy ukrywać komunikatu o promocji i pokazywać licznika,
  • ceny usług nie ulegną zmianie, ponieważ nasz komponent nie jest za nie odpowiedzialny,
  • czas ma być wyświetlany w sekundach, mimo że będzie to mało czytelne,
  • treść nagłówka i informacji o promocji będą przekazane komponentowi jako wartości propsów (wpisane na sztywno w komponencie nadrzędnym),
  • godzina startu i zakończenia promocji może być na razie wpisana na sztywno w kodzie komponentu.

Tworzyliśmy już bardziej skomplikowane funkcjonalności, więc powinniśmy sobie z tym poradzić!

Jak będzie wyglądał ten komponent?

Chcemy napisać precyzyjne testy, więc warto zastanowić się, jak ten komponent powinien być zbudowany. Nie chodzi tutaj o to, jak dokładnie zostanie zaimplementowany, ani jak będą działać poszczególne metody – chodzi o oczekiwane efekty. Pamiętaj bowiem, że zadaniem testów jest sprawdzanie, czy końcowy efekt jest zgodny z planem, a nie tylko samego sposobu implementacji.

Analizując wcześniej zapisane założenia, możemy zaplanować taki komponent w miarę łatwo. Nazwiemy go HappyHourAd, a jego zadaniem będzie wyświetlanie nagłówka "Happy Hour" i komunikatu, który będzie wyświetlał odliczanie albo informację o tym, że aktualnie trwa promocja. Najlepiej będzie nie zakładać, że np. nagłówek to będzie <h3> – zamiast tego posłużymy się nazwami klas, co będzie bardziej elastycznym podejściem.

Co musimy przetestować?

Po przeanalizowaniu założeń oraz zaplanowaniu wyglądu komponentu możemy już dość szybko dojść do tego, co właściwie trzeba będzie przetestować i jakiego zachowania mamy oczekiwać.

Zwróć uwagę, że nie napisaliśmy jeszcze ani jednej linii kodu, a mimo tego możemy już wyobrazić sobie, jakie testy będą nam potrzebne. Nie jest to dużo trudniejsze niż w podejściu "najpierw funkcjonalność, potem testy" – wtedy mieliśmy gotowy kod, na którym mogliśmy się oprzeć, ale tu mamy za to solidne wyobrażenie, jak będzie on wyglądać.

Warto na tym etapie wypisać sobie, co dokładnie będziemy chcieli przetestować. Testy powinny sprawdzać:

  1. czy komponent w ogóle się renderuje i nie powoduje błędu,
  2. czy renderowane są oba elementy (nagłówek i komunikat),
  3. czy nagłówek ma treść przekazaną komponentowi w propsie title,
  4. czy w momencie wyświetlenia strony poza godzinami promocji, komunikat wyświetla odliczanie z odpowiednią liczbą sekund,
  5. czy odliczanie co sekundę zmniejsza wyświetlaną liczbę,
  6. czy w momencie wyświetlenia strony w godzinach promocji, komunikat wyświetla informację o promocji, którego treść znajduje się w propsie promoDescription,
  7. czy jeśli strona została otwarta przed startem promocji, a następnie odliczanie dotarło do zera, to czy zostanie wyświetlony informację o promocji.

I to prawdopodobnie tyle. Czy to za mało? A może za dużo...?

Ile testów jest potrzebne?

Przyjęło się mówić, że liczba testów jest wystarczająca, jeśli nie potrafisz wyobrazić sobie już więcej scenariuszy błędów. Jeśli boisz się, że liczba testów jest za mała, to postaraj zastanowić się, jak moglibyśmy popsuć ten komponent – np. przekazując mu jakieś dziwne wartości propsów.

Na przykład, nasz komponent otrzyma propsy z godzinami startu i końca promocji. Zapewne w przyszłości redaktor strony będzie mógł w jakiś sposób (np. w CMS-ie) ustawić te godziny. Co będzie, jeśli wpisze, że promocja ma zaczynać się o godzinie 25:73 (siedemdziesiąt trzy minuty po godzinie dwudziestej piątej)?

W tym konkretnym przypadku założymy, że godzina będzie zawsze podawana w poprawnym formacie 24-godzinnym, ponieważ komponent nadrzędny będzie docelowo dbał o poprawność wartości propsów. Spróbuj jednak pomyśleć, w jaki inny sposób moglibyśmy zepsuć ten komponent bez ingerowania w jego kod.

Takie dziwne scenariusze nazywa się warunkami brzegowymi, czyli edge cases. Najczęściej opierają się one o nieprzewidziane akcje człowieka albo rzadko występujące sytuacje – przykładem może być sekunda przestępna, która w naszym przypadku nie ma znaczenia, ale w systemach kontroli lotów może spowodować spore problemy.

Jeśli mimo starań nie potrafisz znaleźć takiego scenariusza, w którym komponent mógłby błędnie działać – możesz uznać, że masz wystarczająco dużo testów. :)

Dużą zaletą TDD jest to, że piszemy testy do bardzo małych fragmentów kodu, więc naprawdę łatwo i szybko możemy ustalić wszystkie możliwe scenariusze. W przypadku podejścia nie-TDD, gdzie często mamy do przetestowania multum kodu, o wiele łatwiej o ominięcie jakiegoś przypadku.

Piszemy testy

Przejdźmy do rzeczy. Jak już wiesz, testy jednostkowe możemy trzymać w osobnym folderze __tests__, lub zaraz przy samym testowanym komponencie/funkcji. Będziemy stosować to drugie podejście, tak samo, jak robiliśmy to już w poprzednim module.

Otwórz projekt agencji turystycznej z poprzedniego modułu i w katalogu features stwórz folder na nasz nowy komponent. Nazwij go zgodnie z naszą konwencją, a więc - HappyHourAd. W tym katalogu utwórz trzy pliki: HappyHourAd.js, HappyHourAd.scss i HappyHourAd.test.js. Stosujemy TDD, czyli najpierw zajmiemy się ostatnim z tych plików.

Czy napiszemy najpierw wszystkie testy, aby dopiero potem zabrać się za całą implementację? Zdecydowanie nie! To byłoby podejście "tests first", a nie TDD. Jak mówiliśmy już wcześniej, W TDD staramy się wykonywać testy na bieżąco.

W związku z tym proces będzie następujący:

  • piszemy pierwszy test, który z założenia nie przechodzi, bo dotyczy funkcjonalności, która jeszcze nie została zaimplementowana
  • implementujemy funkcjonalność sprawdzaną przez pierwszy test, aby ten test przechodził pozytywnie,
  • zastanawiamy się, czy kod jest dobrze napisany – czyli czy nie wymaga drobnego refactoringu, aby np. był bardziej logicznie zorganizowany i prostszy do zrozumienia dla innego developera,
  • piszemy drugi test, który z założenia nie przechodzi...
  • implementujemy funkcjonalność sprawdzaną przez drugi test...
  • itd.

Otwórz teraz plik HappyHourAd.test.js – zaczynamy pisanie testów!

Test 1: czy komponent się renderuje

Zaczniemy od najprostszego testu – renderowania komponentu. Ten test ma za zadanie sprawdzać, czy komponent w ogóle się renderuje i nie powoduje błędu. Następnie zaimplementujemy kod w pliku komponentu, który faktycznie się tym zajmie.

Oczywiście trzymamy się podejścia – najpierw test, potem implementacja.

Zacznij od napisania testu. Na razie nie ma tu nowości, więc spróbuj napisać ten test samodzielnie, wzorując się na przykładach z poprzedniego modułu. Następnie, klikając w poniższy guzik, możesz sprawdzić, czy Twój test jest napisany poprawnie.

import React from 'react';
import { shallow } from 'enzyme';
import HappyHourAd from './HappyHourAd';

describe('Component HappyHourAd', () => {
  it('should render without crashing', () => {
    const component = shallow(<HappyHourAd />);
    expect(component).toBeTruthy();
  });
});

Uwaga!

W poprzednim module w przypadku testowania komponentu (np. Hero) czasami używaliśmy shallow pojedynczo dla każdego przypadku testowego (it). Miało to sens, bowiem testowaliśmy komponent z różnymi parametrami, więc to wywołanie za każdym razem było inne. Tutaj sytuacja wydaje się odmienna. Zawsze potrzebujemy tego samego komponentu, sprawdzamy tylko różne jego aspekty.

Czy nie wystarczyłoby więc przygotować tego raz? Na samej górze? Albo użyć do tego funkcji beforeEach?

const component = shallow(<HappyHourAd />);

Bezpieczniej robić to jednak w każdym teście z osobna. Po pierwsze, nawet jeśli w tej chwili wywołujemy go tak samo, to może się to w przyszłości zmienić. Po drugie, możliwe, że w niektórych case'ach będziemy chcieli zmienić coś w otoczeniu, w którym ten komponent wywołamy. Będziemy to robić, np. przy testowaniu liczby sekund wyświetlanej w komunikacie. Wtedy przed wywołaniem komponentu będziemy chcieli zasymulować jakąś konkretną godzinę – np. że w momencie wyświetlenia strony jest godzina 11:00 czasu UTC.

Zgodnie z założeniami TDD, na ten moment nasz test nie może przejść. Ma to sens, skoro nie napisaliśmy jeszcze odpowiedniej implementacji komponentu.

Uruchom więc teraz task test:watch i zobacz, czy konsola faktycznie poinformuje nas o teście, który nie przeszedł.

image

Liczba testów wymieniona w podsumowaniu może być u Ciebie inna niż na powyższym screenie. Ważne jest tylko to, że powinien być tylko jeden test ze statusem failed – test, który przed chwilą napisaliśmy. Jeśli tak jest, to dobrze!

Teraz czas na odpowiednią modyfikację naszego komponentu, aby test został zaliczony. To Twoje zadanie, ale nie będzie to nic trudnego. Dodaj do pliku HappyHourAd.js nowy, pusty reactowy komponent klasowy. Metoda render może być praktycznie pusta. Napisz tylko tyle kodu, ile jest wymagane, aby test przeszedł.

Jeśli wszystko zostało wykonane przez Ciebie poprawnie, to Jest sam powinien zauważyć zmiany, ponieważ włączyliśmy go w trybie --watchAll (za pomocą npm run test:watch). W terminalu powinna pojawić się informacja, że wszystkie testy przechodzą pozytywnie.

image

Teraz pozostaje nam ewentualny refactoring napisanego kodu. Jeśli uważasz, że mimo przejścia testu, dobrze byłoby jakoś zmodyfikować Twój aktualny kod – najlepiej zrobić to właśnie teraz. Pamiętaj tylko, że musisz go zmieniać tak, aby test wciąż przechodził.

Na razie, jednak wstawiliśmy tylko pusty kod komponentu, więc raczej nie mamy niczego do refactorowania. W takim razie zakończyliśmy pierwszy cykl pisania naszego nowego komponentu w podejściu TDD!

Red, Green, Refactor

Cały proces TDD często określa się mianem Red, Green, Refactor. To trochę uproszczenie, ale dobrze oddaje istotę tej metody.

  1. Najpierw piszemy test, który nie przechodzi – faza nazywana Red, ponieważ Jest pokazuje oblany test na czerwono.
  2. Potem piszemy kod, który powinien spowodować, że test przejdzie – to faza Green, ponieważ test będzie w kolorze zielonym.
  3. Na końcu wprowadzamy ew. poprawki w kodzie – faza Refactor.

Jeśli masz problem z zapamiętaniem cyklu TDD, to najłatwiej przyswoić go sobie właśnie jako ten skrót – Red, Green, Refactor.

Test 2: czy renderowane są oba elementy

Kolejny case, w którym przygotowanie testu nie będzie dla Ciebie nowością. Również implementacja kodu w komponencie będzie bardzo prosta. Załóżmy, że nagłówek (np. <h3>) będzie miał klasę title, a komentarz z sekundami może być np. divem z klasą countdown. Musimy sprawdzić, czy oba te elementy istnieją w komponencie. Pamiętaj, że render może zwracać tylko jeden element, więc umieścimy nagłówek i komentarz w divie. Oczywiście, wciąż trzymamy się cyklu Red, Green, Refactor.

Zacznij od napisania testu. W poprzednim module pokazywaliśmy już, jak testować istnienie elementów w komponencie. Spróbuj więc napisać test bez naszej pomocy, a potem porównaj z rozwiązaniem dostępnym pod poniższym guzikiem.

Zwróć uwagę, że będziemy wielokrotnie wykorzystywać selektory '.title' i '.promoDescription'. Aby uniknąć ewentualnych literówek, najlepiej będzie zapisać je na początku pliku w obiekcie select. W testach nie będziemy wpisywać tych selektorów, tylko używać wartości z obiektu select.

// na początku pliku:

const select = {
  title: '.title',
  promoDescription: '.promoDescription',
};

// nowy test:

it('should render heading and description', () => {
  const component = shallow(<HappyHourAd />);
  expect(component.exists(select.title)).toEqual(true);
  expect(component.exists(select.promoDescription)).toEqual(true);
});

W tej chwili test nie powinien jeszcze oczywiście przechodzić (faza Red). Następnie czas na implementację kodu w komponencie. To będzie bardzo proste zadanie: wystarczy dodać w funkcji render oba elementy. Gdy to zrobisz, Jest powinien wskazać, że test zaczyna przechodzić pozytywnie (faza Green).

Na końcu możesz jeszcze poprawić swój kod (faza Refactor). Prawdopodobnie nie będzie to jednak w tym przypadku konieczne.

Test 3: czy nagłówek ma treść z propsa

Treść, której oczekujemy w nagłówku, to wartość propsa title. Twoim zadaniem jest napisanie odpowiedniego testu, a następnie taka modyfikacja komponentu, aby ten test spełniał.

Sam test będzie stosunkowo prosty do napisania. Bardzo podobny znajdziesz również w jednym z przykładów z poprzedniego modułu. Spróbuj napisać go bez naszej pomocy.

Podobnie jak zrobiliśmy to z selektorami w obiekcie select, warto na początku pliku zapisać w stałej mockProps obiekt zawierający testowe propsy naszego komponentu (czyli właściwości title i promoDescription). W tym i wszystkich kolejnych testach będziemy je przekazywać do renderowanego komponentu, czyli użyjemy:

const component = shallow(<HappyHourAd {...mockProps} />);

Pamiętaj, aby trzymać się zasady Red, Green, Refactor – zacznij od napisania testu, następnie zaimplementuj funkcjonalność, a na końcu zastanów się, czy chcesz jakoś poprawić napisany kod.

Test 4: czy komunikat wyświetla odpowiednią liczbą sekund

Ten przypadek będzie trochę trudniejszy. Pozwoli nam jednak nieco lepiej poznać obsługę dat w JavaScripcie.

Musimy sprawdzić, czy komponent potrafi ustalić na samym początku, ile czasu pozostało do rozpoczęcia promocji. Jednak ten test może być przeprowadzony tylko wtedy, kiedy promocja nie jest aktywna. Inaczej test nie będzie miał sensu, ponieważ zamiast liczby sekund pokazywana byłaby informacja o promocji.

Co więcej, nasz test musi znać poprawną odpowiedź, czyli poprawną liczbę, która powinna być wyświetlana! I co mamy teraz zrobić? Przecież test może być wykonywany o różnych godzinach i za każdym razem poprawna liczba byłaby inna!

Na szczęście możemy dość łatwo poradzić sobie z tym problemem! Możemy podmienić funkcję, której nasz komponent użyje do sprawdzenia aktualnej godziny. Dzięki temu komponent będzie myślał, że jest zawsze ta sama godzina – nie zważając kompletnie na stan faktyczny!

Zacznijmy od dodania na końcu pliku HappyHourAd.test.js nowej sekcji describe:

describe('Component HappyHourAd with mocked Date', () => {

});

Jak widzisz z opisu, ten zestaw testów będzie przeprowadzany dla godziny 11:57:58. Zanim przejdziemy do pisania samego testu, musimy zająć się zasymulowaniem aktualnej godziny. Aby to zrobić, musimy przewidzieć, jak zadziała komponent.

W JavaScripcie najczęstsze sposoby poznania aktualnej godziny to:

  • const currentTime = new Date(); – wywołanie konstruktora klasy Date bez żadnych argumentów zwróci instancję tej klasy dla bieżącego momentu,
  • const currentTime = Date.now(); – jeśli nie potrzebujemy instancji klasy Date, tylko tzw. timestamp (wyjaśnienie poniżej), możemy użyć metody Date.now(),
  • const currentTime = new Date(Date.now()); – niektórzy developerzy nie wiedzą o tym, że można wywołać konstruktor Date bez argumentu, więc podają jako argument aktualny timestamp pobrany z Date.now().

Timestamp

Formalnie, timestamp oznacza dowolny znacznik czasu, a w dosłownym tłumaczeniu oznacza pieczątkę z aktualną godziną. Wśród programistów często jednak używa się tego określenia dla bardzo konkretnego formatu zapisu czasu – Unix Timestamp, czyli liczby sekund, które minęły od północy w dniu 01.01.1970.

Nie będziemy teraz wchodzić w szczegóły powodów, dla których wybrano akurat tę datę. Ważne dla nas jest to, że ta liczba jest często stosowana do zapisu daty i godziny.

Timestamp w JS-ie jest jednak jeszcze innym formatem – liczbą milisekund, które minęły od północy w dniu 01.01.1970. Dlatego wykonując jakiekolwiek obliczenia na timestampie, często spotkasz się z dzieleniem przez 1000.

Innym często spotykanym zapisem daty i godziny jest format zgodny ze specyfikacją ISO 8601. Jest on znacznie bardziej czytelny dla nas, ponieważ wygląda tak: 2019-05-14T11:57:58.135Z. Mamy w nim kolejno:

  • rok, miesiąc, dzień,
  • literę T oddzielającą datę od godziny,
  • godziny, minuty, sekundy, milisekundy,
  • oraz literę Z oznaczającą strefę czasową (Zulu, czyli UTC).

O ile timestamp podaje zawsze czas UTC, to format ISO 8601 pozwala na podanie czasu w dowolnej strefie czasowej. Najbardziej przydatną dla nas będzie jednak strefa UTC, o czym powiemy więcej za chwilę.

Stworzenie metody mockDate

W takim razie już wiemy, że potrzebujemy zmienić działanie new Date() oraz Date.now() w taki sposób, aby zawsze zwracały tę samą wartość ustawioną przez nas. Stworzymy więc nową klasę, którą nazwiemy mockDate, dziedziczącą z klasy Date. Dodaj ją wewnątrz dodanego przed chwilą bloku describe.

describe('Component HappyHourAd with mocked Date', () => {
  const mockDate = class extends Date {
    constructor(...args) {
      super(...args);
      return this;
    }
  };
});

Na razie klasa mockDate jeszcze niczego nie robi, ale musieliśmy zacząć od podstaw. Ta klasa rozszerza klasę Date, a więc w tym momencie będzie działać dokładnie tak samo, jak Date. W konstruktorze użyliśmy wyrażenia super, które odwołuje się do konstruktora klasy-rodzica (w tym przypadku Date). Wywołaliśmy ten konstruktor z takimi samymi argumentami, jakie otrzymał nasz konstruktor klasy mockDate. Na końcu konstruktora zwróciliśmy obiekt this.

Jednak chwila, czy zwykle nie używaliśmy składni class nazwaKlasy extends...? Tak jest, ale podobnie jak mogliśmy definiować anonimowe funkcje, tak samo można zdefiniować anonimową klasę i przypisać ją do zmiennej lub stałej. Dzięki temu dbamy o zakres zmiennych – nasza klasa mockDate ma istnieć tylko w tym bloku describe.

Zmieńmy teraz jej sposób działania tak, aby podawała zawsze tę samą godzinę! Ma to się dziać tylko w sytuacji, kiedy nie podano żadnych argumentów. Możemy więc wykorzystać blok if/else:

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  const mockDate = class extends Date {
    constructor(...args) {
      if(args.length){
        super(...args);
      } else {
        super(customDate);
      }
      return this;
    }
  };
});

Dla lepszego uporządkowania kodu datę i godzinę zapisaliśmy w stałej customDate. Wykorzystaliśmy do tego celu format ISO 8601, aby łatwo było nam modyfikować godzinę w razie potrzeby.

Działanie tego kodu jest bardzo proste – jeśli podano jakieś argumenty, to zostanie wywołany konstruktor Date (czyli super) z tymi argumentami. W przeciwnym wypadku wywołamy go z argumentem w postaci daty, którą chcemy zwracać.

W bardzo podobny sposób możemy dodać metodę now. Będzie to metoda statyczna (static), co oznacza, że nie będziemy jej wywoływać na instancji klasy mockDate, ale na tej klasie samej w sobie. Innymi słowy, będzie wywoływana jako mockDate.now().

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  const mockDate = class extends Date {
    constructor(...args) {
      /* ... */
    }
    static now(){
      return (new Date(customDate)).getTime();
    }
  };
});

Podmiana klasy Date

No dobrze, mamy już klasę mockDate, która działa dokładnie tak samo, jak Date, z tym że zawsze zwraca tę samą datę i godzinę. Jednak nasz komponent nie będzie używał mockDate, tylko Date, więc po co nam ta klasa? Wykorzystamy ją do podmiany klasy Date. Moglibyśmy to zrobić np. tak:

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  const mockDate = class extends Date {
    /* ... */
  };

  global.Date = mockDate;
});

Obiekt global

Jak zapewne pamiętasz, w przeglądarce istnieje obiekt window. Znajdują się w nim wszystkie metody i klasy dostępne globalnie w JS-ie. Dla przykładu, kiedy piszemy document.querySelector('.product-list') lub Math.round(), w rzeczywistości wykorzystujemy obiekty window.document i window.Math.

Nasze testy jednostkowe nie są uruchamiane w przeglądarce, tylko w NodeJS – silniku JS, który jest uruchamiany bez przeglądarki. W NodeJS nie ma obiektu window, ale zamiast niego wszystkie globalne metody i klasy znajdują się w obiekcie global.

Dlatego chcąc podmienić klasę Date, korzystamy z global.Date.

Ten sposób ma jednak dla nas poważny problem – klasa Date zostanie zmieniona na stałe. Oznacza to, że nie będziemy mogli w kolejnych testach używać prawdziwej klasy Date, która zwraca aktualną datę i godzinę. Dlatego wewnątrz testu, który za moment napiszemy, będziemy najpierw podmieniać Date na mockDate, a potem przywracać oryginalną wartość Date, którą zapiszemy w stałej trueDate.

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';
  const trueDate = Date;

  const mockDate = class extends Date {
    /* ... */
  };

  it('should show correct at 11:57:58', () => {
    global.Date = mockDate;

    /* ... */

    global.Date = trueDate;
  });
});

Dodanie testu

Mamy już gotową podmianę klasy Date na naszą klasę mockDate, więc możemy napisać test. Nie przejmuj się – sam test będzie bardzo prosty! Na końcu tego describe (pod afterAll) dodaj test:

it('should show correct at 11:57:58', () => {
  global.Date = mockDate;

  const component = shallow(<HappyHourAd {...mockProps} />);
  const renderedTime = component.find(select.descr).text();
  expect(renderedTime).toEqual('122');

  global.Date = trueDate;
});

Sprawdzanie wielu przypadków

Pojawia się jednak teraz pytanie: czy wystarczy przetestować tylko jedną godzinę? Może nasz komponent zadziałałby nieprawidłowo dla dłuższego czasu? A może warto byłoby przetestować też wartości brzegowe, czyli 11:59:59, albo 13:00:00?

Moglibyśmy skopiować cały ten describe i podać inną godzinę. Tylko czy warto się tak powtarzać? Przecież te trzy przypadki są prawie takie same, a wiemy, że jedną z dobrych praktyk jest zasada DRYDon't Repeat Yourself.

Możemy jednak szybko rozwiązać ten problem! Potrzebujemy w tym celu wykonać kilka kroków – pamiętaj, że na niektórych etapach ten kod nie będzie działał poprawnie. Przetestuj go dopiero po wykonaniu wszystkich kroków.

Zaczniemy od przeniesienia trueDate i mockDate poza describe:

const trueDate = Date;
const mockDate = class extends Date {
  constructor(...args) {
    if(args.length){
      super(...args);
    } else {
      super(customDate);
    }
    return this;
  }
  static now(){
    return (new Date(customDate)).getTime();
  }
};

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  it('should show correct at 11:57:58', () => {
    global.Date = mockDate;
    /* ... */
    global.Date = trueDate;
  });
});

Następnie zmienimy mockDate tak, aby była funkcją, która zwraca klasę (tę samą, która teraz jest zapisana w mockDate).

const trueDate = Date;
const mockDate = () => class extends Date {
  constructor(...args) {
    if(args.length){
      super(...args);
    } else {
      super(customDate);
    }
    return this;
  }
  static now(){
    return (new Date(customDate)).getTime();
  }
};

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  it('should show correct at 11:57:58', () => {
    global.Date = mockDate();
    /* ... */
    global.Date = trueDate;
  });
});

W mockDate znajduje się już funkcja, ale w niej wykorzystujemy nadal customDate – stałą, którą definiujemy dopiero wewnątrz describe. W takim razie musimy wewnątrz testu (it) przekazywać wartość daty do funkcji mockDate!

const trueDate = Date;
const mockDate = customDate => class extends Date {
  constructor(...args) {
    if(args.length){
      super(...args);
    } else {
      super(customDate);
    }
    return this;
  }
  static now(){
    return (new Date(customDate)).getTime();
  }
};

describe('Component HappyHourAd with mocked Date', () => {
  const customDate = '2019-05-14T11:57:58.135Z';

  it('should show correct at 11:57:58', () => {
    global.Date = mockDate(customDate);
    /* ... */
    global.Date = trueDate;
  });
});

Skoro teraz wewnątrz describe wykorzystujemy customDate już tylko raz, nie ma potrzeby deklarowania tej stałej – jej wartość możemy użyć bezpośrednio w argumencie mockDate.

const trueDate = Date;
const mockDate = customDate => class extends Date {
  constructor(...args) {
    if(args.length){
      super(...args);
    } else {
      super(customDate);
    }
    return this;
  }
  static now(){
    return (new Date(customDate)).getTime();
  }
};

describe('Component HappyHourAd with mocked Date', () => {
  it('should show correct at 11:57:58', () => {
    global.Date = mockDate('2019-05-14T11:57:58.135Z');
    /* ... */
    global.Date = trueDate;
  });
});

Teraz nasz kod po zmianach powinien już działać – ale nie spoczywamy jeszcze na laurach! Nadal testowanie kilku przypadków wymagałoby od nas powtórzenia kilku linii kodu. Skoro nie chcemy się powtarzać, to możemy pójść krok dalej – cały test it możemy również zamknąć w funkcji! Będzie ona potrzebowała tylko dwóch argumentów – czasu i oczekiwanej wartości:

const trueDate = Date;
const mockDate = customDate => class extends Date {
  /* ... */
};

const checkDescriptionAtTime = (time, expectedDescription) => {
  it(`should show correct at ${time}`, () => {
    global.Date = mockDate(`2019-05-14T${time}.135Z`);

    const component = shallow(<HappyHourAd {...mockProps} />);
    const renderedTime = component.find(select.descr).text();
    expect(renderedTime).toEqual(expectedDescription);

    global.Date = trueDate;
  });
};

describe('Component HappyHourAd with mocked Date', () => {
  checkDescriptionAtTime('11:57:58', '122');
  checkDescriptionAtTime('11:59:59', '1');
  checkDescriptionAtTime('13:00:00', 23 * 60 * 60 + '');
});

Teraz w describe potrzebujemy tylko wykonać kilkukrotnie funkcję checkDescriptionAtTime, która zawiera definicję testu, wykorzystującego funkcję mockDate do tymczasowej podmiany Date na klasę, która będzie zawsze zwracała tę samą wartość (dla pojedynczego testu).

W ostatnim z testów – tym dla godziny 13:00 – pozwoliliśmy sobie na nieco lenistwa. Zamiast podawać tekst, który powinien wyświetlić się w naszym komponencie, wykonaliśmy działanie: liczbę godzin (23 godziny od 13:00 do 12:00) przemnożyliśmy przez 60 (liczba minut w godzinie) i ponownie przez 60 (liczba sekund w minucie). Na końcu dodaliśmy pusty ciąg znaków, aby skonwertować liczbę na tekst.

Nazwaliśmy to lenistwem, ale taki zapis ma bardzo ważny sens – sprawi, że inny developer z zespołu (albo my sami za jakiś czas) będzie miał dużo mniej trudności ze zrozumieniem tego fragmentu kodu!

Rozwój komponentu HappyHourAd

Po napisaniu testu jesteśmy w fazie Red i możemy przejść do implementacji tej funkcjonalności. Możesz spróbować zmierzyć się z tym samodzielnie, ale biorąc pod uwagę dość skomplikowany test, który musieliśmy napisać, zostawiamy poniżej gotowe rozwiązanie tej funkcjonalności.

Najpierw jednak wyjaśnimy kilka zagadnień, które przydadzą Ci się niezależnie od tego, czy zdecydujesz się spróbować samodzielnie, czy tylko przeanalizować i zrozumieć poniższe rozwiązanie.

Obliczenie ilości sekund do najbliższego południa może wydawać Ci się bardzo żmudnym zadaniem, ale wcale nie będzie takie trudne. Pewnym wyzwaniem może być jednak poradzenie sobie ze strefami czasowymi. Należy bardzo uważać na wykorzystywane metody klasy Date, aby nie pomylić czasu UTC z czasem w lokalnej strefie czasowej użytkownika.

Całe szczęście, klasa Date ma wbudowane metody, takie jak Date.UTC czy getUTCHours, które pozwalają nam operować na czasie UTC. Obiekt Date zajmie się też konwersją czasu lokalnego na UTC. Dlatego najwygodniej będzie nam stworzyć dwie stałe zawierające instancje Date. Jedna z nich będzie wskazywała aktualny czas, a druga – najbliższą godzinę 12:00.

W naszym komponencie HappyHourAd dodamy nową metodę – getCountdownTime:

getCountdownTime(){
  const currentTime = new Date();
  const nextNoon = new Date(Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate(), 12, 0, 0, 0));

  if(currentTime.getUTCHours() >= 12){
    nextNoon.setUTCDate(currentTime.getUTCDate()+1);
  }

  return Math.round((nextNoon.getTime() - currentTime.getTime())/1000);
}

W metodzie render możesz wykorzystać wartość zwracaną przez tę funkcję, np. tak:

<div className='promoDescription'>{this.getCountdownTime()}</div>

Do przeanalizowania metody getCountdownTime może Ci się przydać dokumentacja obiektu Date.

Po zaimplementowaniu tej funkcjonalności sprawdź, czy testy zaczęły przechodzić. Jeśli tak, to wszystko poszło dobrze (faza Green). Na końcu możesz ew. zrefaktorować kod, jeśli uważasz, że da się go poprawić (faza Refactor).

Test 5: czy odliczanie co sekundę zmniejsza wyświetlaną liczbę

Czas na sprawdzenie kolejnego wymagania – licznik powinien co sekundę zmniejszać wyświetlaną wartość.

Zwróć uwagę, że na tym etapie nie musimy już sprawdzać, czy początkowo wyświetlana jest poprawna wartość. To jest sprawdzane przez wcześniejsze testy, więc teraz możemy się skupić tylko na tym, czy wyświetlany czas poprawie się zmienia.

Z napisaniem tego testu już sobie poradzisz samodzielnie, prawda? Przy poprzednim teście nauczyliśmy się, jak możemy zasymulować konkretną godzinę. Natomiast do poczekania, aż wyświetli się nowa wartość, moglibyśmy użyć setTimeout, aby kolejne sprawdzenie wyświetlanej wartości wykonało się np. po dwóch sekundach.

Problem w tym, że taki test rzeczywiście będzie czekał dwie sekundy. Jeśli będziemy chcieli sprawdzić trzy przypadki – to będzie 6 sekund. Do tego będziemy za chwilę sprawdzać, czy po doliczeniu do zera wyświetli się informacja o promocji – to będzie kolejnych kilka sekund... Łatwo sobie wyobrazić, że niedługo nasz test wykonywałby się długimi minutami!

Na szczęście Jest oferuje nam proste wyjście. Wystarczy użyć metody jest.useFakeTimers(). Pozwala ona symulować przebieg czasu, m.in. w setTimeout oraz setInterval. Komponentowi będzie się wydawało, że faktycznie minęły dwie sekundy, podczas gdy w rzeczywistości minie raptem kilka milisekund.

Musimy też pamiętać, aby zmienić godzinę zwracaną przez naszą klasę mockDate – inaczej mimo wykonania funkcji zawartej w setTimeout, nasz komponent będzie myślał, że jest nadal ta sama godzina.

Tworzymy nowy test w oparciu o poprzedni

Zacznij od skopiowania funkcji checkDescriptionAtTime i wklejenia jej ponownie, tym razem pod nazwą checkDescriptionAfterTime (After zamiast At). Dodaj w nim drugi argument (tzn. pomiędzy pierwszym a drugim) o nazwie delaySeconds. Dla czytelności możesz też zmienić opis testu (pierwszy argument w it). Pierwsze dwie linie tej nowej funkcji powinny wyglądać teraz tak:

const checkDescriptionAfterTime = (time, delaySeconds, expectedDescription) => {
  it(`should show correct value ${delaySeconds} seconds after ${time}`, () => {

Pod tymi liniami (czyli pomiędzy liniami z it i z global.Date) dodaj linię jest.useFakeTimers();, a na samym końcu testu (pod ostatnią linią z global.Date) dodaj jest.useRealTimers();. Dzięki temu bieg czasu w JS-ie wykonywanym pomiędzy tymi liniami będzie kontrolowany przez Jesta.

Następnie pomiędzy linią montującą komponent (shallow) a linią odczytującą tekst z komunikatu (const renderedTime) wstaw poniższy kod:

const newTime = new Date();
newTime.setSeconds(newTime.getSeconds() + delaySeconds);
global.Date = mockDate(newTime.getTime());

jest.advanceTimersByTime(delaySeconds * 1000);

Pierwsze trzy linie tego fragmentu są odpowiedzialne za ustawienie nowej metody Date. Najpierw pobieramy "aktualną" datę i godzinę – pamiętamy jednak, że wcześniej podmieniliśmy Date na klasę, która zawsze zwróci nam tę samą godzinę. W drugiej linii modyfikujemy tę godzinę, dodając do niej wartość argumentu delaySeconds, a następnie podmieniamy Date na nowy mock ze zmienioną godziną. Dzięki temu od teraz Date będzie zwracał czas późniejszy o tyle sekund, ile podaliśmy w argumencie delaySeconds.

Ostatnia linia powyższego fragmentu odpowiedzialna jest za kontrolę biegu czasu w JS-ie. Nasz komponent co sekundę sprawdza aktualny czas (za pomocą Date) i na jego podstawie wyświetla odpowiednią wartość. Oznacza to, że co sekundę komponent na nowo się renderuje. Za pomocą metody advanceTimersByTime przyspieszamy bieg czasu właśnie po to, aby wykonało się kolejne odświeżenie komponentu.

To może być nieco mylące, że kontrolujemy dwa rodzaje czasu, więc podsumujmy jeszcze raz:

  • Klasa Date odpowiedzialna jest za sprawdzenie aktualnego czasu (lub zdefiniowanego przez nas, udającego aktualny czas).
  • Timery, kontrolowane przez useFakeTimers i advanceTimersByTime, wpływają na to, kiedy zostanie wykonana funkcja przekazana do setTimeout lub setInterval.

Te dwa rodzaje czasu działają niezależnie od siebie i dlatego potrzebujemy kontrolować je osobno. Możesz to sobie wyobrazić jako zegarek i stoper – dwa osobne urządzenia, z których pierwsze mówi nam, która jest godzina, a drugi przypomina o tym, żeby co sekundę spojrzeć na zegarek.

Musimy pamiętać o posprzątaniu po sobie – dlatego na końcu testu użyliśmy metody useRealTimers, aby wyłączyć timery symulowane przez Jesta i przywrócić JS do normalnego trybu działania.

Nasza nowa funkcja checkDescriptionAfterTime jest już gotowa, możemy ją teraz wykorzystać. Skopiuj poprzedni blok describe razem z zawartością. Zmień treść opisu, zamień nazwę wywoływanej funkcji z checkDescriptionAtTime na checkDescriptionAfterTime, i w każdym jej wywołaniu pomiędzy pierwszym a drugim argumentem wstaw opóźnienie. Pamiętaj, aby odpowiednio dostosować oczekiwane wartości!

My wybraliśmy poniższe wartości, ale śmiało możesz użyć innych:

describe('Component HappyHourAd with mocked Date and delay', () => {
  checkDescriptionAfterTime('11:57:58', 2, '120');
  checkDescriptionAfterTime('11:59:58', 1, '1');
  checkDescriptionAfterTime('13:00:00', 60 * 60, 22 * 60 * 60 + '');
});

Implementacja testowanej funkcjonalności

Kod odpowiedzialny za wyświetlanie poprawnego czasu jest wykonywany w metodzie render (która wywołuje metodę getCountdownTime), więc do zaimplementowania tej funkcjonalności potrzebujemy tylko dodać odświeżanie widoku komponentu co sekundę. W tym celu musimy dodać fragment kodu wykonywany w momencie stworzenia instancji komponentu, który zainicjuje odświeżanie widoku. Najprościej będzie w komponencie HappyHourAd dodać konstruktor:

constructor(){
  super();

  /* run this.forceUpdate() every second */
}

W miejscu komentarza musisz wpisać kod, który co sekundę wykona this.forceUpdate(). Możesz do tego wykorzystać setInterval, ale pamiętaj, aby użyć w nim funkcji strzałkowej, ponieważ funkcja anonimowa (ze słowem function) zmieniłaby znaczenie this i nasz kod nie zadziałałby poprawnie.

Po uzupełnieniu kodu sprawdź, czy testy przechodzą (Green), a następnie zastanów się, czy widzisz potrzebę poprawienia czytelności kodu (Refector).

Test 6: czy komunikat wyświetla informację o promocji przy otwarciu strony

Ten przypadek jest już znacznie prostszy. Pomiędzy godzinami 12:00:00 i 12:59:59 (włącznie z nimi), zamiast sekund w elemencie .promoDescription powinien pojawiać się tekst z propsa promoDescription. Twoim zadaniem jest – jak zawsze w TDD – najpierw napisanie testów, a potem poprawne zaimplementowanie tej funkcjonalności.

Do tego celu możesz skopiować describe z testu 4, czyli ten, w którym wykorzystujemy checkDescriptionAtTime (At, nie After). Ta funkcja tworzy dla nas test sprawdzający, czy tekst w komunikacie jest poprawna o określonej godzinie. Dokładnie tego potrzebujemy! Pamiętaj, że oczekiwana wartość komunikatu jest zapisana w mockProps.promoDescription.

Uwaga! Nie zapomnij o sprawdzeniu wartości brzegowych – 12:00:00 i 12:59:59.

Przy implementacji tej funkcjonalności najlepiej będzie w metodzie render zapisać do stałej wartość zwracaną przez this.getCountdownTime(). Jeśli ta liczba jest większa niż równowartość 23 godzin, to ma zostać wyświetlona informacja o promocji (przekazywana w propsie). W przeciwnym wypadku powinna zostać wyświetlona wartość tej stałej, co da taki sam efekt, jak do tej pory.

Test 7: czy komunikat wyświetla informację o promocji, kiedy odliczanie dotarło do zera

Czas na ostatni przypadek, z którym również poradzisz sobie bez problemu. Tym razem skopiujemy describe z testu 5, w którym wykorzystywaliśmy funkcję checkDescriptionAfterTime (After, nie At). Dzięki niej sprawdzisz, czy komponent zachowa się poprawnie, kiedy test rozpocznie się przed godziną 12:00, a treść komunikatu sprawdzimy po godzinie 12:00. W takiej sytuacji komponent będzie początkowo wyświetlał liczbę sekund (to już testowaliśmy wcześniej), a kiedy minie 12:00, powinien zacząć wyświetlać informację o promocji.

W związku ze sposobem, w jaki zaimplementowaliśmy nasz komponent, ten test powinien od razu być zaliczony (Green).

Podłączamy komponent do aplikacji

Udało nam się skończyć implementację tego komponentu w podejściu TDD! Czas na jego praktyczne wykorzystanie!

Zanim jednak to zrobimy, dodamy jeszcze style tego komponentu. Do pliku HappyHourAd.scss wstaw następujący kod:

.component {
  position: relative;
  text-align: center;
  color: #fff;
}

.title, .promoDescription {
  position: absolute;
  width: 100%;
}

.title {
  bottom: 40px;
}

.promoDescription {
  bottom: 10px;
}

Przy importowaniu staraj się zachować spójność z resztą aplikacji, a więc załaduj style do obiektu styles:

import styles from './HappyHourAd.scss';

I to właśnie z tego obiektu korzystaj w render. Dla przykładu wrapper (czyli div, w którym znajdują się tytuł i komentarz) powinien otrzymać props:

className={styles.component}

Analogicznie zastosuj klasy title i promoDescription.

Za chwilę zaimportujemy ten komponent do Hero (znajdziesz go w katalogu components/layouts), ale nadal trzymamy się TDD, więc najpierw dodamy test do Hero.test.js. Sprawdzimy tylko, czy komponent HappyHourAd w ogóle w nim istnieje. Wzorując się na innych testach z tego pliku, nowy test może wyglądać tak:

it('should render HappyHourAd', () => {
  const expectedTitle = 'Lorem ipsum';
  const expectedImage = 'image.jpg';
  const component = shallow(<Hero titleText={expectedTitle} imageSrc={expectedImage} />);

  expect(component.find('HappyHourAd').length).toEqual(1);
});

Następnie w komponencie Hero, pod obrazkiem dodaj div z className={styles.happyHour}, a w nim umieść komponent HappyHourAd. Aby ten komponent był widoczny, w pliku Hero.scss dodaj style:

.happyHour {
  z-index: 999;
  position: absolute;
  bottom: 20px;
  left: 0;
  right: 0;
}

Efektem powinien być taki widok na głównej stronie:

image

Podsumowanie

Jak widzisz, TDD może być na początku trochę męczące. W końcu, żeby napisać cokolwiek w aplikacji, najpierw wszystko musimy opakować testami. Nie sposób jednak nie docenić, jak pomocny taki sposób staje się podczas implementacji.

Wszystkie błędy pokazywane są na bieżąco, możemy dzięki temu bardzo szybko je wyłapać. W tym podejściu pisanie testów nie jest przykrym obowiązkiem na samym końcu projektu, ale elementem planowania nowych funkcjonalności.

Dzięki testowaniu małych fragmentów łatwiej jest nam sobie z nimi poradzić i nie musimy zastanawiać się, co właściwie mieliśmy przetestować. A do tego, od samego początku mamy swego rodzaju dokumentację, która mówi nam, jak mniej więcej dana funkcjonalność powinna działać. Daje nam to wygodne ramy, których możemy się trzymać przy implementacji.

Co więcej, możemy być spokojni, że gdyby jakiekolwiek przyszłe zmiany w kodzie miały zepsuć dotychczasową funkcjonalność, zostaniemy o tym od razu poinformowani.

Zadanie: Komponent HappyHourAd w TDD

Jak wspomnieliśmy na początku tego submodułu, Twoim zadaniem jest wykonanie wszystkich poleceń, które w nim się znajdują. Innymi słowy, efektem tego zadania powinien być komponent HappyHourAd wykonany w podejściu TDD. Zastosuj wszystkie testy wymienione w tym submodule i zaimplementuj sprawdzane przez nie funkcjonalności.

20.3. Testy integracyjne

Na tym etapie kursu zapewne nie musimy Cię już przekonywać co do użyteczności testów. Na razie jednak mieliśmy styczność tylko z jednym ich rodzajem. To za mało, aby dać nam pewność, że nasza aplikacja na pewno działa poprawnie. Testy jednostkowe mogą wydawać się wystarczający buforem bezpieczeństwa, ale... wcale nie są. Gwarantują Ci one tylko tyle, że dana część programu (komponent, funkcja itp.) działa poprawnie w izolacji. Nie mówią nam niczego o działaniu aplikacji jako większej całości.

Co mamy na myśli? Prawie każda aplikacja w dużej mierze opiera się na interakcji modułów ze sobą. Możemy mieć np. komponent listy kontaktów, który renderuje komponenty poszczególnych kontaktów, a te z kolei – np. komponenty guzików, które mają wykonać jakąś akcję po kliknięciu. Takich sytuacji, nawet w naszej aplikacji biura podróży, jest mnóstwo. I właśnie to, a więc interakcje pomiędzy komponentami również musimy testować. To, że jeden czy drugi komponent jest zbudowany poprawnie i spełnia wymagania testów jednostkowych nie jest równoznaczne z tym, że interakcja pomiędzy tymi komponentami też działa poprawnie.

I tutaj to gry wchodzi drugi rodzaj testów, o którym wspominaliśmy w poprzednim module – testy integracyjne. Ich rolą jest testowanie wszelkiej interakcji pomiędzy różnymi częściami kodu. I mowa tu nie tylko o współpracy komponentów z innymi komponentami – może to być też współdziałanie komponentu z funkcją czy też współpraca funkcji ze sobą.

Sama interakcja też może być bardzo różna – może to być przekazywanie argumentu, odbieranie zdarzenia, a może po prostu zwracanie jakichś danych.

W tym submodule zajmiemy się więc właśnie testami integracyjnymi. Od razu jednak wyjaśnijmy jedną kwestię. W poprzednim module mówiliśmy, że testy integracyjne rzadko są rolą junior developera i faktycznie tak jest. To już trochę bardziej skomplikowany temat niż testy jednostkowe. Dlatego też nie będziemy zamęczać Cię bardzo trudnymi przykładami. Jednak nawet spotkanie z prostymi przykładami testów integracyjnych bardzo poszerzy Twoje rozumienie całego zagadnienia testowania.

Testy integracyjne na przykładzie

Dość teorii. Żeby lepiej zobrazować rolę testów integracyjnych, posłużymy się prostym przykładem. Ten przykład nie dotyczy naszej aplikacji agencji turystycznej. Jeśli chcesz, możesz założyć na jego potrzeby osobny projekt, ale nie musisz tego robić (ten przykład nie będzie częścią zadania).

Powiedzmy, że piszemy prosty kalkulator. Nie będzie to nic specjalnego – będzie tylko sumował dwie liczby. Nasza aplikacja mogłaby zawierać następujące funkcje:

//app.js

export const add = (a, b) => a + b;
export const formatResult = (res) => ('Result: ' + res);
export const sumResult = (a, b) => formatResult(add(a, b));

Pierwsza funkcja (add) ma służyć tylko do obliczeń, a druga (formatResult) powinna tylko zajmować się sformatowaniem komunikatu z wynikiem. Trzecia funkcja (sumResult) korzysta z obu wcześniejszych funkcji do wykonania obliczenia oraz zwrócenia sformatowanego komunikatu. Proste, prawda?

Wiemy już jednak, że nawet w teorii prosty kod warto przetestować. Jak mogłyby wyglądać więc testy jednostkowe w naszej aplikacji?

describe('add', () => {
  it('should add two numbers properly', () => {
    expect(add(1, 2)).toBe(3);
    expect(add(3, 6)).toBe(9);
  })
});

describe('formatResult', () => {
  it('should return proper text', () => {
    expect(formatResult(5)).toBe('Result: 5');
    expect(formatResult(6)).toBe('Result: 6');
  })
});

Jak zapewne się domyślasz, oba powyższe testy jednostkowe zwrócą nam sukces. A jak wyglądałyby testy integracyjne?

describe('add and formatResult', () => {
  it('should show text result of adding two numbers', () => {
    const addResult = add(1, 2);
    const formattedResult = formatResult(addResult);
    expect(formattedResult).toBe('Result: 3');
  })
});

describe('sumResult', () => {
  it('should show text result of adding two numbers', () => {
    const formattedResult = sumResult(1, 2);
    expect(formattedResult).toBe('Result: 3');
  })
});

Testy integracyjne sprawdzają, czy połączenie kilku rzeczy (funkcji, komponentów, instancji klas, etc.) działają poprawnie. W tym wypadku test zgłosi błąd, jeśli którakolwiek funkcja nie zadziała poprawnie, lub jeśli np. wartość zwracana przez jedną funkcję jest w innym formacie, niż spodziewa się tego druga funkcja.

Czyli podsumowując testy integracyjne to takie testy, które bazują na zewnętrznych elementach i testują ich integrację oraz komunikację.

Nieważne czy mówimy o zwykłych funkcjach (jak teraz) czy np. komponentach (częsta sytuacja w React), zasada zawsze będzie taka sama. Błąd w którymś z elementów oznacza błąd w całym teście integracyjnym.

Testy jednostkowe vs integracyjne

Nie zawsze łatwo jest określić, co jest testem jednostkowym, a co integracyjnym. Spójrz na testy, które pisaliśmy w pliku HappyHourAd.test.js – sprawdzają one poprawne działanie komponentu HappyHourAd, ale nie ingerują w to, czy ten komponent implementuje logikę swojego działania w konstruktorze, w metodzie render, czy w jeszcze innej metodzie. Co więcej, gdybyśmy teraz przenieśli metodę HappyHourAd.getCountdownTime do osobnego pliku i zaimportowali ją w komponencie HappyHourAd, to korzystałby on z zewnętrznej funkcji, więc tym bardziej nasze testy – pisane jako jednostkowe – zbliżyłyby się do definicji testów integracyjnych.

Ta granica nie jest sztywna i często zależy od kontekstu. Test integracyjny kilku metod komponentu może być jednocześnie testem jednostkowym komponentu. Dla developera najważniejsze jest czy testy zapewniają poprawne działanie aplikacji – a w przypadku błędu, czy pomagają ustalić miejsce wystąpienia tego błędu.

Weźmy powyższy przykład – gdybyśmy nie testowali osobno funkcji add i formatResult i zastosowali wyłącznie testy integracyjne, nadal wychwycilibyśmy błąd którejkolwiek z tych funkcji. Są one bardzo proste, więc znalezienie błędu nie byłoby problemem.

Natomiast jeśli mówimy o komponentach reactowych posiadających kontenery, gdybyśmy testowali wyłącznie integrację kilku komponentów, znalezienie błędu mogłoby stanowić poważny problem – dlatego testy jednostkowe poszczególnych komponentów są nam potrzebne.

Dlatego najlepiej nie ograniczać się wyłącznie do testów stricte jednostkowych, ale oprócz nich warto korzystać również z testów z pogranicza jednostkowych i integracyjnych.

Wracamy do projektu biura podróży

Pokazaliśmy już prosty przykład testu integracyjnego, teraz jednak czas na wykorzystanie tej idei w trochę większej aplikacji. Ponownie wrócimy do naszej aplikacji agencji turystycznej.

Musisz przyznać, że format czasu w naszym komponencie nie jest zbyt ładny. Jeśli do rozpoczęcia promocji pozostało 20 czy 40 sekund, nie wygląda to bardzo źle – ale kiedy do następnego "Happy Hour" musimy czekać więcej niż kilka minut, to widoczna liczba sekund staje się wręcz monstrualna.

Niedługo Twoim zadaniem będzie napisanie nowej funkcji w folderze utils, która posłuży nam do konwersji liczby sekund na bardziej czytelny format. Zaczniemy jednak od zupełnie innej strony – stworzymy pustą funkcję i użyjemy jej, mimo że nie będzie jeszcze gotowa. Wtedy zajmiemy się testami komponentu HappyHourAd, aby oddzielić testy jednostkowe tego komponentu od testów integracyjnych.

Zalążek funkcji formatTime

W katalogu utils stwórz plik formatTime.js i wklej do niego poniższy kod:

export const formatTime = () => 'formatted time';

Jak widzisz, ta funkcja będzie zawsze zwracać ten sam ciąg znaków. Oczywiście, jest to tylko stan przejściowy, ale pozwoli nam wykorzystać tę funkcję w komponencie HappyHourAd i zrozumieć stosowanie mockowania funkcji w testach jednostkowych.

Przejdź teraz do komponentu HappyHourAd i w metodzie render znajdź fragment kodu odpowiedzialny za decyzję, czy ma zostać wyświetlona informacja o promocji, czy czas do najbliższego południa. W tym drugim przypadku liczba sekund powinna zostać przekazana do funkcji formatTime, a w komponencie powinniśmy wyświetlić wartość zwracaną przez tę funkcję.

Nie zapomnij o zaimportowaniu tej funkcji w pliku komponentu HappyHourAd!

W efekcie, poza godzinami 12:00-13:00, zamiast liczby sekund powinien pojawiać się tekst "formatted time". Oznacza to też, że zaczną zgłaszać błędy wszystkie testy sprawdzające poprawność wyświetlanego odliczania czasu do kolejnej promocji. Zaraz jednak się tym zajmiemy!

Wydzielenie testów integracyjnych

Zaczniemy od stworzenia pliku HappyHourAd.int.js, który będzie zawierał testy integracyjne.

Skopiuj do niego całą zawartość pliku HappyHourAd.test.js, a następnie usuń pierwszy describe – ten, w którym sprawdzamy tylko, czy komponent nie zwraca błędu, zawiera elementy DOM na tytuł i komentarz, oraz czy tytuł ma poprawną treść. Skasuj też dwa ostatnie describe, w których testujemy czy wyświetla się informacja o promocji.

W rezultacie w pliku HappyHourAd.int.js powinny zostać tylko te testy, które sprawdzają, czy wyświetla się odpowiednia liczba sekund.

Zwróć uwagę, że Jest nie będzie wykonywał testów z tego pliku, ponieważ w jego nazwie nie ma członu test – zrobiliśmy to specjalnie, ponieważ te testy zaczną działać dopiero po poprawnej implementacji funkcji formatTime. Dopiero wtedy zmienimy jego nazwę na HappyHourAd.int.test.js.

Struktura katalogów

W niniejszym przykładzie testy integracyjne dla komponentu HappyHourAd i funkcji formatTime umieściliśmy w folderze, w którym jest sam komponent. Obok jego testów jednostkowych. Często spotkasz się jednak zapewne z ideą, aby testy integracyjne trzymać osobno. Np. w folderze tests/int w głównym katalogu aplikacji. W przypadku bardziej skomplikowanej aplikacji również warto rozważyć taką możliwość.

Naprawienie testów jednostkowych

Zamykamy plik HappyHourAd.int.js i wracamy do pliku HappyHourAd.test.js. Chcemy, aby testy w tym pliku były testami jednostkowymi komponentu HappyHourAd, czyli mają one być niezależne od funkcji formatTime.

W poprzednim module stosowaliśmy mockowanie funkcji – czyli stosowanie atrapy rzeczywistej funkcji – do sprawdzenia, czy została wykonana. W tym module mockowaliśmy obiekt Date, aby zwracała zawsze taką samą wartość. W tym przypadku również zastosujemy mockowanie, ale tym razem będziemy mockować funkcję importowaną z innego pliku.

Wystarczy, że przed pierwszym describe wstawimy ten fragment kodu:

beforeAll(() => {
  const utilsModule = jest.requireActual('../../../utils/formatTime.js');
  utilsModule.formatTime = jest.fn(seconds => seconds);
});

Pierwsza linia importuje plik formatTime – zamiast import używamy jednak jest.requireActual, aby upewnić się, że importujemy faktyczny kod tego pliku, a nie jego zmockowaną wersję. Następnie zmieniamy znajdującą się w nim funkcję formatTime na mock funkcji, który zawsze zwróci argument przekazany tej funkcji.

Wywoływanie kodu przed i po testach

Przy pisaniu testów mamy do dyspozycji różne funkcje, dostarczane przez Jesta. Pozwalają nam one uniknąć powielania kodu wewnątrz poszczególnych testów (it). Należą do nich:

  • beforeAll – wykonuje operacje przed wszystkimi testami w tym describe,
  • afterAll – wykonuje operacje po wszystkich testach w tym describe,
  • beforeEach – wykonuje operacje przed każdym testem w tym describe,
  • afterEach – wykonuje operacje po każdym teście w tym describe.

Jeśli umieścimy którąś z tych funkcji poza describe (np. na początku pliku), zadziała ona dla wszystkich testów w tym pliku. Możemy również umieścić te funkcje wewnątrz describe i wtedy zadziałają tylko dla testów w tym describe.

Dzięki temu, kiedy w naszym komponencie wykonujemy funkcję formatTime z jakimś argumentem, to po prostu zwróci ona otrzymany argument. Oczywiście, dotyczy to wyłącznie testów w tym pliku.

Po dodaniu tego fragmentu kodu powinniśmy już widzieć, że wszystkie testy przechodzą.

Zadanie: Implementacja funkcji formatTime

Twoim zadaniem jest:

  • implementacja funkcji formatTime zgodnie z TDD,
  • zastosowanie testów integracyjnych komponentu HappyHourAd.

Implementacja formatTime

Funkcja formatTime ma za zadanie przyjmować czas w sekundach i zwracać go w formacie hh:mm:ss. Przeprowadzimy Cię przez cały proces od strony testów. Po Twojej stronie będzie stała za to kwestia implementacji odpowiedniego kodu zgodnie z wymaganiami, które w tych testach się znajdą. Cały proces znowu będziemy prowadzić zgodnie z TDD.

Końcowym efektem powinno być następujące działanie komponentu HappyHourAd.

image

Zacznij od stworzenia pliku na testy funkcji formatTime. Z racji tego, że ta oraz pozostałe funkcje w utils są bardzo małe, możemy zastosować jeden plik do testów – nazwijmy go utils.test.js. Oczywiście, idea "jeden plik testu do jednego pliku z funkcją" też nie była zła, ale w przypadku tak małych funkcji takie rozdzielanie na wiele plików nie jest konieczne.

Założenia

Zastanówmy się teraz, jak ta funkcja ma działać. Powinna ona przyjmować czas w sekundach (np. jako argument). Następnie jej zadaniem jest przeliczenie, ile da nam to osobno godzin, minut i sekund. Czyli np. formatTime(122) powinno zwrócić nam 00:02:02. Przy okazji widzimy tutaj też drugie założenie. Jeśli godzina, minuta czy sekunda nie jest większa niż 9, to dana liczba powinna być rozpoczęta przedrostkiem z zerem. Czyli np. formatTime(122) nie może nam zwrócić 0:2:2, zamiast tego oczekujemy na 00:02:02. Do tego byłoby dobrze, gdyby podanie złej wartości jako argumentu albo w ogóle jego nieprzekazanie powodowało zwrócenie null.

Co musimy przetestować?

Podsumujmy więc, co będziemy musieli sprawdzić:

  • Czy jeśli nie podano argumentu, to funkcja zwróci null?
  • Czy jeśli podano coś innego niż liczbę, to funkcja zwróci null?
  • Czy jeśli podano liczbę mniejszą niż zero, to funkcja zwróci null?
  • Czy jeśli podano poprawny argument, to funkcja zwróci dobry czas w formacie hh:mm:ss?
  • Czy jeśli godzina, minuta albo sekunda jest mniejsza niż 0, to otrzymuje przedrostek? (ten test możemy połączyć z poprzednim)

Pierwszy test – Czy jeśli nie podano argumentu, to funkcja zwróci null?

Czas na pierwsze test.

Wejdź do pliku utils.test.js i wklej poniższy kod:

import { formatTime } from './formatTime';

describe('utils', () => {
  describe('formatTime', () => {

    it('should return null if there is no arg', () => {
      expect(formatTime()).toBe(null);
    });

  });
});

Włącz teraz Jesta (task test:watch).

W tej chwili nasz test powinien zwrócić oczywiście błąd (fazę Red). Twoją rolą jest teraz taka modyfikacja pliku formatTime.js, żeby eksportował on nową funkcję (formatTime). Funkcja ta musi mieć jeden parametr i jeśli przy jej odpaleniu jego wartość nie będzie ustalona, to powinna ona zwrócić null. Po Twoich zmianach test powinien już przejść (faza Green).

Czy jeśli podane coś innego niż liczbę, to funkcja zwróci null?

Ponownie my piszemy test, a Twoją rolą jest taka modyfikacja naszej funkcji, aby go spełniała.

it('should return null if arg is not a number', () => {
  expect(formatTime('abc')).toBe(null);
  expect(formatTime(() => {})).toBe(null);
});

Sprawdzamy tutaj dwa przypadki – podanie tekstu albo funkcji. Dlaczego nie wystarczy jeden? Zawsze lepiej sprawdzić przynajmniej dwie możliwości. W końcu istnieje możliwość, że wadliwa funkcja zwraca coś na sztywno.

Czy jeśli podano liczbę mniejszą niż zero, to funkcja zwróci null?

Jak wyżej.

it('should return null if arg is lower than zero', () => {
  expect(formatTime(-1)).toBe(null);
  expect(formatTime(-2)).toBe(null);
});

Czy jeśli podano poprawny argument, to funkcja zwróci dobry czas w formacie hh:mm:ss?

Ten test połączymy od razu z kolejnym: Czy jeśli godzina, minuta albo sekunda jest mniejsza niż 0, to otrzymuje przedrostek?

it('should return time in hh:mm:ss if arg is proper', () => {
  expect(formatTime(122)).toBe('00:02:02');
  expect(formatTime(3793)).toBe('01:03:13');
  expect(formatTime(120)).toBe('00:02:00');
  expect(formatTime(3604)).toBe('01:00:04');
});

Przydatne informacje

Funkcja formatTime przyjmuje liczbę sekund. Jak obliczyć z niej liczbę godzin, minut i sekund?

Zacznijmy od sekund – nasz format hh:mm:ss ma wyświetlać sekundy z zakresu od 0 do 59. Innymi słowy, będzie to reszta z dzielenia (modulo, czyli %) argumentu funkcji przez 60. Na wypadek, gdyby argument nie był liczbą całkowitą, warto osiągnięty wynik zaokrąglić w dół za pomocą Math.floor.

Przy minutach będzie już nieco bardziej skomplikowanie – najpierw musimy argument funkcji podzielić przez 60. Dalsze kroki będą analogiczne do poprzedniego przypadku – obliczamy resztę z dzielenia przez 60, a otrzymany wynik zaokrąglamy w dół.

Godziny obliczymy bardzo podobnie do minut, z tym że w pierwszym kroku podzielimy argument funkcji przez 3600, a drugi krok pominiemy (nie musimy obliczać reszty z dzielenia). Wynik, tak jak poprzednio, zaokrąglimy w dół.

Pozostaje jeszcze kwestia zer – jeśli z powyższego obliczenia wyjdzie nam np. liczba minut równa 3, to jak zamienić ją na tekst '03'? Znalezienie rozwiązania należy do Ciebie, ale podpowiemy Ci, że ta operacja po angielsku nazywa się zero-padding. Ta informacja bardzo Ci się przyda, ponieważ będzie to okazja do treningu znajdowania ciekawych rozwiązań w internecie.

20.4. Testy w praktyce

Wiesz już, czym są testy jednostkowe, na czym polega TDD, również testy integracyjne nie są już dla Ciebie tajemnicą. Prawdopodobnie jednak nie czujesz się jeszcze do końca pewnie w tym temacie. Często pomagaliśmy Ci przy pisaniu testów i narzucaliśmy, jakie wymagania należy testować. W tym submodule będzie inaczej.

Poniżej przedstawimy trzy funkcjonalności. Twoim zadaniem jest wybranie jednej z nich i zaimlementowanie jej w naszej aplikacji biura podróży. Całość powinna być wykonana przy tym zgodnie z ideą TDD. Nie będziemy narzucać Ci, jak dokładnie "pod maską" ma być zbudowana dana funkcjonalność, musisz jednak spełnić podane wymagania. Co do testów, to też nie będziemy mówić Ci wprost, co powinno być sprawdzone. Staraj się bez naszej pomocy zastanowić, jakie testy będą potrzebne. Nie bój się, że o czymś zapomnisz. To w końcu forma treningu. Masz prawo nie zaplanować ich jeszcze idealnie.

Uwaga – możesz wykorzystywać zarówno testy jednostkowe, jak i integracyjne. Możesz również wykorzystywać takie możliwości Jesta, których nie używaliśmy we wcześniejszych materiałach. Wiedza, którą masz już obecnie, powinna Ci jednak wystarczyć.

Zadanie: Trening podejścia TDD

Opcja 1 – odliczamy czas do lata

Na górze Hero należy dodać nowy komponent daysToSummer, którego zdaniem jest pokazywanie, ile dni pozostało do lata (21 czerwca).

Komponent powinien działać następująco:

  • Jeśli lato już trwa (21 czerwca - 23 września), to komponent nie powinien niczego pokazywać.
  • Jeśli aktualna pora roku jest inna niż lato, to komponent powinien pokazywać liczbę dni do najbliższego 21 czerwca w formacie <days> days to summer.
  • Jeśli liczba dni jest równo jeden, to zamiast days, tekst powinien używać słowa day (1 day to summer!).

Pamiętaj, że liczbę dni trzeba ustalić tylko raz, na starcie. Nie musimy przejmować się, że jest np. 23:59:59 i za chwilę liczba dni się zmieni. Ważne jest tylko to, jaki był dzień przy wejściu na stronę.

Uwaga – klient prosi o opieranie się tylko na czasie UTC. Czyli nieważne, jaki dzień jest u użytkownika, ważne, jaki w strefie czasowej naszego zleceniodawcy (UTC).

W testach możesz wzorować się na tym, co robiliśmy już przy pracy nad komponentem HappyHourAd. W implementacji skorzystaj z pomocy JS-owgo obiektu Date. W razie problemów możesz również szukać podobnych przykładów w internecie. Co do samej integracji nowego komponentu do Hero, to wzoruj się na tym, co zrobiliśmy w przypadku HappyHourAd. Nie zapomnij też o odpowiednim teście.

Końcowy efekt mógłby wyglądać następująco:

image

Opcja 2 – cena podczas "Happy hour

Klient chciałby zachęcić użytkowników do kupna wycieczek poprzez pokazywanie najpierw ceny podczas "Happy Hour", a dopiero później standardowej. Twoim zadaniem jest dodanie na podstronie podglądu wycieczki jeszcze jednej ceny – pomniejszonej o 20% podczas "Happy Hour". Ma być ona większa od ceny standardowej, tak aby jako pierwsza rzucała się w oczy.

Powinno to wyglądać następująco:

image

Oprócz tego klient chciałby, aby ta mniejsza cena była pokazywana również:

  • w opisach wycieczek na podstronie Trips (w miejscu "price from" i właściwej ceny, powinniśmy widzieć "price from" i zmniejszoną cenę),
  • w opisie wycieczki na górze podstrony Trip (w miejscu "price from" i właściwej ceny, powinniśmy widzieć "price from" i zmniejszoną cenę).

Wykonanie może być dowolne. Nasza propozycja jest jednak taka, aby stworzyć nową funkcję (np. promoPrice) w utils i na niej się oprzeć. Funkcja ta powinna przyjmować dwa argumenty – kwotę i procenty, o które powinna być pomniejszona. Działanie tej funkcji powinno być dość proste. Np. promoPrice(200, 20) powinno zwrócić nam 160. Gdy już będzie gotowa, reszta zadania powinna być już stosunkowo łatwa.

Opcja 3 – różne numery telefonów

Klient pozwala, aby jego pracownicy obsługiwali klientów telefonicznie poza biurem. Dlatego też każdy pracownik obsługi ma swój własny telefon służbowy z indywidualnym numerem. Tym samym numer telefonu na stronie nie powinien być stały, lecz różny zależnie tego, który pracownik pełnie aktualnie dyżur telefoniczny. Dodatkowo, jeśli to godziny nocne, a więc nikt nie pełni dyżuru, to zamiast numeru telefonu powinien pokazywać się komunikat The office opens at 8:00 UTC.

Twoim zadaniem jest więc taka modyfikacja aplikacji, aby numer telefon był zgodny z poniższą listą:

  • 8:00 - 12:00 - Amanda, 678.243.8455
  • 12:00 - 16:00 - Tobias, 278.443.6443
  • 16:00 - 22:00 - Helena, 167.280.3970

Uwaga – pamiętaj, że opieramy się na strefie czasowej klienta. Nieważne więc, jaka jest lokalna godzina użytkownika, Ty bierz pod uwagę tylko strefę czasową UTC.

Numer telefonu jest teraz częścią komponentu Header. Dla ułatwienia pracy i samych testów dobrze byłoby go wydzielić jako nowy komponent. W zdaniu możesz często zaglądać do przykładu z HappyHourAd. Może być dla Ciebie niezwykle pomocny.

Gdy zadanie będzie już gotowe, zmodyfikowaną aplikację wyślij swojemu Mentorowi. Jeżeli zostanie Ci czas, możesz też zaimplementować pozostałe funkcjonalności z listy. Pamiętaj jednak, że do zaliczenia zadania wystarczy zakodowanie jednej z opcji.

20.5. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on powtórzyć wiedzę z poprzednich modułów.

Odpowiedzi tego quizu nie są nigdzie zapisywane, więc są tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

1. W kodzie HTML znajduje się element div, a w nim jego bezpośredni potomek, który jest linkiem. Które właściwości CSS nadane na ten div odziedziczy jego dziecko (zakładając, że nie nadamy na element potomny żadnych innych stylów)?

Wyjaśnienie

W CSS niektóre właściwości mogą być dziedziczone przez elementy potomne.

Zwykle właściwość color jest dziedziczona, ale przeglądarki nadają linkom własne, domyślne kolory, więc jeżeli nie nadamy na element-dziecko dodatkowych stylów, nie odziedziczy on koloru czcionki po elemencie-rodzicu.

To, które właściwości się dziedziczą, a które nie, możesz sprawdzić w specyfikacji CSS. Jednak znacznie szybszym i wygodniejszym sposobem, w razie takich wątpliwości, jest przetestowanie tego za pomocą narzędzi developerskich lub CodePena.

2. Które z poniższych tagów HTML mają znaczenie semantyczne?

Wyjaśnienie

Tagi o znaczeniu semantycznym wskazują przeglądarce, jaka treść się w nich znajduje. Przeglądarka zatem "wie", że treść między znacznikiem <h2> a </h2> to nagłówek, wewnątrz tagu <footer> to stopka itp. Oprócz tagów semantycznych istnieją także tagi niesemantyczne (generyczne), czyli takie, które nic nie mówią przeglądarce na temat tego, co znajduje się wewnątrz nich. Takimi tagami są div i span. Nieco więcej na temat semantyki możesz przeczytać tutaj.

3. Które poniższe stwierdzenia na temat HTML5 są prawdziwe?

Wyjaśnienie

Mogło Cię zaskoczyć, jak wiele z powyższych twierdzeń jest fałszywych. HTML5 jest dość "liberalny" w kwestii tego, co będzie działać, co absolutnie nie znaczy, że jest to zachęta do pisania niestarannego kodu. Specyfikacja HTML5 ściśle określa, w jaki sposób przeglądarki powinny parsować kod, tak by w każdej pojawiło się takie samo drzewo DOM.

Jeżeli chodzi o semantyczne elementy HTML5, takie jak <header> i <footer>, swobodnie możemy używać wielu "egzemplarzy" każdego z nich na stronie, tam, gdzie pasują do "kontekstu". Przykładowo, tagu <footer> możemy użyć nie tylko do stopki całej strony, ale też np. do stopki każdego posta na blogu.

20.6. Praca zespołowa w Gicie

Już wkrótce czeka Cię praca w projekcie zespołowym. Dlatego w tym miejscu poszerzymy nieco Twoje wiadomości o Gicie, aby dać Ci trochę czasu na zapoznanie się z tym tematem.

W tym submodule dowiesz się, jak używać repozytorium w pracy zespołowej. Opanowanie tego będzie bardzo pomocne podczas projektu tworzonego z innymi programistami. Wykorzystaj ten czas na ćwiczenia i zrozumienie specyfiki Gita, aby później móc w pełni skupić się na zadaniach projektowych.

Git

Umiesz już pracować na zdalnym repozytorium. Teraz jednak pojawi się nowe wyzwanie – praca wielu developerów w tym samym projekcie. Na szczęście, Git pomoże nam poradzić sobie z tym wyzwaniem!

Zanim przejdziemy dalej, wyjaśnijmy sobie pojęcie odgałęzienia (ang. branch). Do tej pory wszystkie operacje w naszym repozytorium wykonywaliśmy na branchu o nazwie master. Jest to domyślny, główny branch w prawie każdym repozytorium. W Twoim repozytorium drugi commit wynika z pierwszego, a trzeci z drugiego, czyli każdy commit jest następstwem poprzedniego i leżą na tej samej "gałęzi".

image

Odgałęzienia pozwalają na to, aby po którymś commicie rozdzielić te ścieżki, a następnie z powrotem je połączyć.

image

Dzięki temu możliwe są różne scenariusze pracy, np. kilkoro developerów może pracować równolegle nad jednym projektem, albo możesz w swoim projekcie pracować nad kilkoma niezależnymi funkcjonalnościami.

Zwykle też stosuje się zasadę, że do brancha master lądują tylko działające wersje — czyli np. projekt z gotowymi trzema podstronami. Od tej wersji na innym odgałęzieniu pracuje się np. nad kolejną podstroną, i dopiero kiedy ją skończysz scala swoje zmiany z branchem master.

Branche - zaczynamy pracę na odgałęzieniach

Wiesz już czym jest odgałęzienie, czyli branch. Przejdźmy teraz do praktyki. W naszych przykładach w tym submodule będziemy udawać, że nad kodem HTML w Twoim projekcie pracuje dwoje developerów — Tadek i Kasia. Tadek zmienia tylko treści, a Kasia — klasy.

Projekt do treningu pracy na branchach

Do treningu pracy na branchach możesz stworzyć nowy projekt albo wykorzystać projekt learning-git założony w trakcie nauki podstaw Gita.

Jeśli wybierzesz pierwszą opcję, stwórz plik index.html z dowolną zawartością.

Otwórz projekt, na którym trenujesz znajomość Gita. Na początku sprawdź historię zmian oraz czy nie masz jakichś zmian w plikach, które nie zostały zapisane w commicie — jeśli jakieś są, zapisz commit. Historia Twojego projektu powinna pokazywać przy najnowszym commicie branche master i origin/master (jeśli tak nie jest, wykonaj git push).

image

Dodajmy sobie teraz nowy branch na najnowszym commicie. Wykonaj komendę git branch devTadek i ponownie wyświetl historię zmian.

image

Na razie niewiele się zmieniło — przy najnowszym commicie pojawiło się tylko oznaczenie naszego nowego brancha. Sprawdźmy, co się stanie, kiedy dodamy nowy commit. Dodaj paragraf tekstu tuż przed zamknięciem body w pliku index.html i zapisz nowy commit.

image

Przy nowym commicie jest teraz tylko oznaczenie HEAD -> master, które oznacza, że ten commit jest w branchu master, oraz że właśnie na tym branchu w tej chwili pracujemy — tzn. że nowe commity będą zapisywane w branchu master.

Co oznacza HEAD?

Słowo HEAD w Gicie oznacza commit, w oparciu o który pracujemy. Zwykle będzie to najnowszy commit w branchu, na którym pracujemy.

Pamiętaj, że zdalne repozytorium nie różni się niczym od lokalnego — w nim również jest branch master (oznaczony jako origin/master) oraz HEAD (oznaczony jako origin/HEAD).

Poprzedni commit jest dalej oznaczony branchami origin/master (master na repozytorium zdalnym), origin/HEAD (aktualna wersja na repozytorium zdalnym) oraz devTadek (nasz nowy branch lokalny). Przełączmy się teraz na ten ostatni branch za pomocą komendy git checkout devTadek i sprawdźmy, co się zmieni w historii zmian.

image

Zadziało się teraz kilka istotnych rzeczy:

  1. oznaczenie HEAD -> jest teraz przy branchu devTadek, co mówi nam, że przełączyliśmy się na ten branch i teraz nowe commity będą dodawanego właśnie do tego odgałęzienia,
  2. jeśli sprawdzisz teraz plik index.html, zobaczysz że nie ma w nim najnowszych zmian,
  3. w wyniku git tree nie widzimy najnowszego commita, który wykonaliśmy w branchu master – to normalne zachowanie, ponieważ git tree pokazuje nam tylko historię istotną z punktu widzenia aktywnego brancha. Aby zobaczyć wszystkie branche, użyj komendy git tree --all.
image

Zanim przejdziemy dalej przypomnijmy:

  • git branch nazwa-brancha tworzy nowy branch,
  • git checkout nazwa-brancha zmienia aktywny branch.

Jeśli chcesz wykonać obie te operacje na raz, wystarczy że wpiszesz komendę git checkout -b nazwa-brancha. Przetestujmy to tworząc branch devKasia i przełączając się na niego za pomocą komendy git checkout -b devKasia.

image

Zarówno branch devKasia, jak i devTadek są oznaczone przy tym samym commicie — tzn. ten commit jest najnowszym w obu tych branchach. Mimo tego, widzimy że tylko jeden z branchy jest aktywny.

Jak zapewne pamiętasz, Kasia zmienia klasy, więc dodaj teraz klasę na jednym elemencie, np. h1, i zapisz commit. Następnie wykonaj komendę git tree --all.

image

Zauważ, że wzdłuż lewej krawędzi okna terminala pojawiły się dodatkowe znaki. Tworzą one prosty graf, który pokazuje przebieg zmian w branchach. Nadal commit na samej górze jest najnowszy, ale nie wynika on z drugiego commita, wykonanego na branchu master.

Powtórzmy to samo dla brancha devTadek – zanim spojrzysz na kolejną ilustrację, spróbuj samodzielnie wykonać następujące zadania:

  1. przełącz się na brancha devTadek,
  2. zmień treść w h2 lub p (ważne, aby ta zmiana nie była w tej samej linii, w której wprowadzaliśmy zmiany na innych branchach),
  3. zapisz nowy commit i wyświetl historię dla wszystkich branchy.
image

I znów powtórka — tym razem dla brancha master zmieniamy title wewnątrz head i zapisujemy commit. Następnie wracamy na branch devKasia, dodajemy klasę na body i zapisujemy commit. Zobaczmy jak po tych wszystkich operacjach wygląda historia zmian dla wszystkich branchy.

image

Zauważ, że commity nie są wyświetlane chronologicznie. Komenda git tree automatycznie poukładała nam commity według branchy (tj. została domyślnie dodana flaga --topo-order), aby pokazać uporządkowany graf. Możemy zmienić to za pomocą flagi --date-order.

image

Jest to dokładnie ten sam graf, jednak w związku z chronologicznym ułożeniem commitów, ścieżki grafu muszą się krzyżować. Dlatego tego widoku będziemy używać tylko, kiedy istotna będzie chronologiczna kolejność commitów.

Możesz teraz przełączać się pomiędzy branchami, aby zobaczyć, jak będzie zmieniać się wykres historii repozytorium oraz zawartość w plikach. Nie dodawaj jednak kolejnych commitów, aby nie powodować konfliktów (nie zmieniać tej samej linii kodu w więcej niż jednym branchu). Konfliktami zajmiemy się nimi w późniejszym rozdziale.

Pamiętasz jeszcze komendę git diff, za pomocą której porównywaliśmy dwa commity? Podawaliśmy wtedy ich identyfikatory, bądź nazwy tagów, które na nich ustawiliśmy. Możesz teraz porównywać zmiany podając w tej komendzie nazwy branchów. W ramach ćwiczenia sprawdź, czym różnią się od siebie:

  • branche devTadek i devKasia,
  • branch devKasia i commit przy którym jest origin/master,
  • branch devTadek i commit przy którym jest origin/master.

Ważne - branch master

W naszych przykładach pominęliśmy jedną z kluczowych dobrych praktyk.

Najnowszym commitem w branchu master powinna być zawsze wersja projektu, która jest gotowa do opublikowania.

Nie oznacza to, oczywiście, że nie można zapisywać commitów, które nie są wersjami gotowymi do opublikowania — ale takie commity zapisujemy na innym branchu. Nie jest to również problemem przy pracy zespołowej, ponieważ każdy branch można wysłać do zdalnego repozytorium za pomocą komendy git push.

Wytłumaczymy to na przykładzie: zaczynając pracę nad projektem możesz od razu pracować na branchu homepage. Możesz zapisywać kolejne commity np. po dodaniu kodu HTML nagłówka, ostylowaniu go, dodaniu responsive, etc. Po zakończeniu prac nad stroną główną i przetestowaniu pod względem ew. błędów, możesz wykonać merge (czyli scalenie zmian, opisane w kolejnym rozdziale) do brancha master. Następnie rozpoczynasz pracę np. na branchu contact-page.

Dzięki temu, jeśli klient poprosi Cię o podgląd bieżącej wersji, będziesz zawsze wiedzieć, że na branchu master jest wersja, która nadaje się do zaprezentowania klientowi.

Co więcej, w późniejszym rozdziale dowiesz się, w jaki sposób publikować swoje strony za pomocą usługi GitHub Pages - wtedy zawsze branch master będzie opublikowany na serwerze. Tym bardziej w takiej sytuacji nie chcemy publikować np. nieostylowanych elementów lub stron z błędami.

Dlatego powtórzymy jeszcze raz — nigdy nie zapisuj commitów na branchu master. Przełączaj się na ten branch wyłącznie w celu wykonania scalenia zmian (merge) z innego brancha.

Merge - scalamy zmiany z różnych odgałęzień

W naszym projekcie mamy równolegle wprowadzane zmiany w branchach master, devKasia i devTadek. Czas scalić te zmiany do jednej wersji w branchu master.

W tym celu przełącz się na branch master i sprawdź, jak wygląda historia zmian wszystkich odgałęzień.

image

Operacja merge zaktualizuje nasz aktualny branch (master) o zmiany wprowadzone we wskazanym w komendzie branchu (w tym przypadku devKasia). W tym celu wpisz komendę git merge devKasia. Po tej komendzie wyświetli się edytor opisu, analogicznie jak przy zapisywaniu commita. W naszym wypadku możemy z niego po prostu wyjść, a następnie sprawdzić historię repozytorium.

Może pojawić się konflikt

W zależności od zmian wykonanych w plikach, w trakcie komendy git merge może pojawić się konflikt, o czym poinformuje Cię komunikat w terminalu.

W takim wypadku możesz:

  • przejść do rozdziału "Rozwiązywanie konfliktów", aby nauczyć się jak go rozwiązać, lub
  • wykonać ponownie ten rozdział w innym projekcie (może być to nowy projekt), upewniając się, że pomiędzy liniami zmienianymi w różnych branchach występuje przynajmniej jedna linia, która nie ulega zmianie.
image

Przeanalizujmy, co się wydarzyło. W branchu master pojawił się nowy commit o nazwie "Merge branch 'devKasia'". Za to sam branch devKasia pozostał niezmieniony i jest nadal na równoległej ścieżce w grafie.

Wykonajmy teraz ponowny merge, tym razem zaktualizujemy branch master o zmiany z brancha devTadek. Jeśli wszystko poszło pomyślnie, możesz obejrzeć wykres historii repozytorium w domyślnym, oraz chronologicznym sortowaniu.

image image

Za chwilę będziemy kontynuować pracę na branchach master oraz devKasia, więc potrzebujemy, aby ten drugi "dogonił" pierwszy. W tym celu potrzebujemy zrobić ponownie merge, tyle że tym razem chcemy zaktualizować branch devKasia o zmiany z brancha master. W tym celu musimy przełączyć się na branch, który chcemy zaktualizować, a dopiero potem wykonać merge.

Komendy do wykonania to:

  • git checkout devKasia
  • git merge master
image

Tym razem przy merge'u nie powstał nowy commit. Wynika to z tego, że w momencie wykonywania merge'a, na tych dwóch branchach nie było zmian na dwóch, równoległych ścieżkach. Na razie wystarczy jak zapamiętasz, że przy merge'u nie zawsze powstanie nowy commit.

Przykładowy scenariusz pracy zespołowej

Wyobraź sobie, że dołączasz do projektu, nad którym cały czas pracuje inny developer. Twoim zadaniem będzie wprowadzenie zmian, o które poprosił klient po obejrzeniu wstępnej wersji projektu.

Lepiej nawet sobie nie wyobrażać jak miałaby wyglądać Wasza praca bez Gita. Co chwilę musielibyście rozmawiać o tym, czy możesz wprowadzić zmianę w jakimś pliku, bo może akurat kolega z zespołu coś zmienił w tym pliku. I zapewne wysyłalibyście sobie pliki mailami, zaśmiecając sobie skrzynki mailowe dziesiątkami zipów z projektem

Git rozwiązuje te problemy i pozwala, aby każdy pracował nad swoimi zadaniami. Możesz przełączyć się na commit zawierający dokładnie tę wersję projektu, którą zobaczył klient, mimo że było to już kilka dni temu i w tym czasie główny developer pracował dalej nad projektem. Zacznijmy od spojrzenia, jak może wyglądać historia commitów:

image

Jak już wiesz, przyjęło się, że główny branch nazywa się master i założymy, że to właśnie w tym branchu główny developer zapisuje kolejne działające wersje (szerzej omówimy tę kwestię w późniejszym rozdziale). Dlatego na poniższej ilustracji na zielono zaznaczyliśmy gdzie znajduje się aktualnie najnowszy commit w branchu master. Natomiast na czerwono zaznaczyliśmy commit, od którego musisz zacząć pracę.

image

Jak widzisz, powyższa ilustracja pokazuje stan już po przełączeniu się na wybrany commit. W tym celu w komendzie git checkout podaliśmy identyfikator commita zamiast nazwy brancha.

Kiedy już przełączysz się na wspomniany commit, utworzysz na nim nowe odgałęzienie, czyli branch. Na tym etapie niewiele się zmieni:

image

Kiedy zaczniesz wprowadzać zmiany i zapiszesz commit, zobaczysz, że teraz Twoje zmiany biegną równolegle do zmian na branchu master. Komenda git treepokaże wtedy odgałęzienie na wykresie. Dokładnie o to nam chodziło — dzięki temu możesz spokojnie pracować dalej nad uwagami od klienta.

image

Załóżmy, że kończysz pracę nad tymi zmianami akurat w momencie, kiedy główny developer szykuje się do zaprezentowania nowych stron. W związku z tym potrzebujecie scalić Wasze zmiany do wspólnej wersji. Taka operacja nazywa się merge i została przedstawiona na poniższej ilustracji:

image

Merge może przebiec bez żadnych problemów albo mogą wystąpić konflikty. Git jednak świetnie radzi sobie, nawet jeśli zmiany były wprowadzane w tym samym pliku, o ile jest w stanie rozróżnić te zmiany. Oznacza to, że jeśli w kodzie była jakaś linia zmieniona przez Ciebie, ale nie była ona zmieniana w drugim branchu, Git powinien poradzić sobie z tą sytuacją bezkonfliktowo.

Jeśli jednak w obu branchach zmieniana była ta sama linia kodu albo został dopisany nowy kod na końcu pliku, Git nie będzie wiedział którą zmianę zachować, albo w jakiej kolejności dodać nowe linie z obu branchów. Wtedy powstanie konflikt, tzn. w plikach zostaną oznaczone fragmenty, z którymi Git ma problem. Pozwoli Ci to na rozwiązanie konfliktów, czyli ręczne doprowadzenie pliku do takiego stanu, w jakim powinien być po zakończeniu merge’a.

Ze względu na to, że merge łączy zmiany z dwóch branchów, warto pamiętać o sprawdzeniu projektu po wykonaniu merge’a. Pozwoli to uniknąć sytuacji, kiedy w czasie pracy na dwóch branchach zmieniły się np. style dla body, przez które Twoje zmiany mogą po merge’u wyglądać inaczej niż w trakcie pracy nad zmianami.

Akurat w tym wypadku mieliście szczęście — nie było żadnych konfliktów. Kiedy główny developer pracował nad nowymi podstronami, Twoim zadaniem było poprawienie wcześniejszych, więc nie wchodziliście sobie w drogę, nawet kiedy zmienialiście te same pliki .scss. Dzięki temu merge został wykonany automatycznie i zajął tylko kilka sekund. Po sprawdzeniu wysyłacie podgląd projektu klientowi, który dzięki Waszej równoległej pracy zobaczy zarówno poprawki wykonane po jego uwagach, jak i nowy fragment projektu.

Rozwiązywanie konfliktów

Jak już wspomnieliśmy, konflikt występuje w sytuacji, kiedy Git nie potrafi "domyślić się" w jaki sposób ma pogodzić zmiany. Zwykle ma to miejsce, kiedy zmiany na dwóch branchach zostały wprowadzone:

  • w tej samej linii tego samego pliku, lub
  • na końcu pliku.

Zanim jednak przejdziemy do rozwiązywania konfliktu, musimy stworzyć sobie przykładowy konflikt.

Jak stworzyć konflikt?

Przełącz się na branch master i wprowadź zmiany:

  • dodaj słowo "master" do title w head,
  • dodaj akapit ze słowem "master" tuż przed zamknięciem body.

Następnie, zapisz commit, przełącz się na branch devKasia i wprowadź zmiany w tych samych miejscach — tym razem jednak zadbaj o to, aby w każdej wprowadzanej zmianie było słowo "Kasia" zamiast "master". Dzięki temu będzie nam łatwiej zobaczyć, jak wygląda konflikt.

Ponownie zapisz commit i przełącz się z powrotem na master, a następnie wykonaj merge zmian z devKasia do master.

image

Jeśli wszystko poszło zgodnie z planem, pojawił się komunikat o powstaniu konfliktu i konieczności naprawienia konfliktu.

Jak rozwiązać konflikt?

Komunikat o konflikcie informuje nas które pliki wymagają naszej uwagi. Warto zaznaczyć, że do naprawienia będziemy mieli tylko konfliktowe zmiany — pozostałe zostają automatycznie połączone.

Otwórz w edytorze plik, w którym wystąpił konflikt. Plik powinien wygląda podobnie do tego:

image

Widzimy tutaj dwa konflikty, zaznaczone poniżej:

image

Każdy z konfliktów składa się z:

  • linii "<<<<<<<" z oznaczeniem jednej z wersji — w naszym przypadku jest to HEAD, czyli aktualna wersja na dysku,
  • fragmentu kodu z powyższej wersji
  • linii "=======", która rozdziela dwie wersje kodu,
  • tego samego fragmentu kodu, ale z wersji podpisanej poniżej,
  • linii ">>>>>>>" z oznaczeniem drugiej wersji — w naszym wypadku jest to nazwa brancha devKasia.

Rozwiązanie konfliktu polega na doprowadzeniu pliku do stanu, w jakim ma być po merge'u. Załóżmy, że chcemy zachować zmiany z brancha master, czyli w obu konfliktach chcemy zostawić tylko pierwszy fragment kodu (nad linią "======="). Oznacza to, że będziemy usuwać wszystkie pozostałe linie w konfliktach, wymienione na powyższej liście, czyli linie z oznaczeniami wersji, linię rozdzielającą oraz drugi fragment kodu. W naszym przykładzie kod po zmianach wygląda następująco:

image

Po zapisaniu pliku możemy przejść do kontynuowania merge'a. W tym celu musimy wykonać commit, z tym że bez tytułu commita. Będą to więc komendy:

  • git add .
  • git commit

Użycie git status oraz git add

Przy większej ilości plików może być przydatne używanie komendy git status do sprawdzania, w których plikach pozostały jeszcze konflikty do rozwiązania.

W tym celu po rozwiązaniu konfliktów w pojedynczym pliku należy wykonać komendę git add nazwa-pliku, aby git status na bieżąco pokazywał, w których plikach konflikty zostały już rozwiązane.

Procedura wyglądałaby wtedy następująco:

  1. git merge nazwa-brancha,
  2. edycja pierwszego pliku, rozwiązanie konfliktów,
  3. git add nazwa-pierwszego-pliku,
  4. git status aby sprawdzić listę plików, w których jeszcze mamy konflikt do rozwiązania,
  5. powtórzenie kroków 2-4, jeśli istnieje jeszcze jakiś plik z konfliktami,
  6. git commit.

Dalej merge będzie wykonany podobnie do sytuacji bez konfliktu, czyli wyświetli się edytor opisu, a po jego zamknięciu zostanie stworzony nowy commit w branchu master.

Przećwicz jeszcze raz wywoływanie oraz rozwiązywanie konfliktu. Tym razem spróbuj wykonać konflikt w dwóch plikach – ale zamiast merge'a wykonaj Pull Request.

Pull Request

Pull Request, nazywany czasem Merge Request, to prośba o zgodę na merge dwóch branchy. Najczęściej stosuje się go w sytuacji, gdy chcemy np. na branchu master dbać o jakość kodu. Wtedy nie chcielibyśmy, żeby każdy developer mógł dodać do tego brancha swoje zmiany, bez zatwierdzenia przez co najmniej jednego z pozostałych developerów.

Aby wykonać PR (Pull Request), wystarczy że nie będziesz merge'ować dwóch branchy – zamiast tego, po wysłaniu nowych commitów na jakiś branch (w naszym przykładzie niech będzie to branch inny niż master) wejdź na stronę projektu na GitHubie i kliknij guzik "New pull request". Wyświetli się wtedy strona z dwoma dropdownami, pozwalającymi wybrać:

  1. do którego brancha mają zostać wcielone zmiany,
  2. na którym branchu są zmiany, które mają być zmerge'owane.

W pierwszym z nich pozostaw master, a w drugim wybierz branch ze swoimi zmianami. Na dole strony możesz sprawdzić, jakie zmiany zawiera nowo przesłany branch w stosunku do brancha master.

Po wykonaniu Pull Requesta zostanie on sprawdzony – na razie przez jednego z Mentorów, a w późniejszym etapie projektu również przez innych developerów. Dopiero po zatwierdzeniu PR-a będzie wykonany merge.

Stash, czyli coś-jakby-tymczasowy-commit

W pracy z Gitem pojawiają się czasem sytuacje, w których potrzebujemy zapisać tymczasowo zmiany w plikach, bez zapisywania commita.

Przykładem może być sytuacja, w której po godzinie zmian w kodzie orientujemy się, że nie przełączyliśmy się na właściwy branch. W takiej sytuacji nie można po prostu użyć komendy git checkout, ponieważ grozi to wystąpieniem konfliktu pomiędzy aktualnymi zmianami w plikach, a ich wersją z innego brancha.

Innym przykładem będzie sytuacja, w której nie chcesz zapisywać commita, ponieważ nie chcesz commitować kodu z bugiem, ale potrzebujesz szybko wykonać inne zmiany w kodzie.

W takich sytuacjach możesz korzystać ze stasha, który można nazwać "roboczym commitem". Wykonujemy go za pomocą komendy git stash.

image

Jak widzisz, git status pokazywał zmiany w pliku index.html, ale po wykonaniu komendy git stash te zmiany nie są już widoczne dla Gita. Po sprawdzeniu treści pliku również zobaczysz, że zmiany zostały cofnięte.

Możesz zapisywać wiele stashów jednocześnie — z tego względu warto znać komendę, która wyświetli listę zapisanych stashów, czyli git stash list.

image

Zwróć uwagę, że identyfikator i tytuł commita nie odnoszą się do zmian, które zostały zapisane w stashu, ale do ostatniego zapisanego commita. Może to być bardzo istotne w momencie, kiedy zmiany z przywracanego stasha kolidują z aktualną wersją plików.

Zacznijmy jednak od prostszej sytuacji, czyli przywrócenia zmian ze stasha bez wcześniejszego wprowadzania jakichkolwiek innych zmian. Wykonujemy tę operację za pomocą komendy git stash apply.

image

W ten sposób został przywrócony stan plików sprzed wykonania komendy git stash. Nasz "roboczy commit" nie został jednak usunięty, co możemy sprawdzić za pomocą komendy git stash list. Po zakończeniu przywracania zmian ze stasha i upewnieniu się, że zmiany w plikach działają, jak chcieliśmy, możemy usunąć stash za pomocą komendy git stash drop.

W przypadku posiadania wielu stashów możemy w komendach git stash apply oraz git stash drop używać identyfikatorów wyświetlanych przez komendę git stash list, np. stash@{1}. Pamiętaj jednak, że identyfikatory mogą się zmienić po dodaniu bądź usunięciu stasha.

image

Materiały do pogłębienia znajomości Gita

Przypominamy listę materiałów, które mogą Ci pomóc w pogłębieniu umiejętności posługiwania się Gitem.

W razie wątpliwości co do którejkolwiek z komend, możesz skorzystać z dokumentacji, która szczegółowo opisuje zastosowanie każdej z komend.

20.7. Zaawansowane techniki Gita

Tematów związanych z Gitem jest bardzo dużo, a technik pracy na nim jeszcze więcej. W tej drugiej części uzupełnimy Twoją wiedzę o ważne zagadnienia — rebase do mastera, łączenie commitów i kopiowanie commitów.

Na pewno dobrze już znasz komendę git merge i wiesz jak wygląda merge'owanie branchów. Teraz przedstawimy Ci inną technikę, która nazywa się rebase.

Ta technika jest nieco bardziej zaawansowana, więc gorąco zachęcamy Cię do przetestowania jej na tymczasowym repozytorium stworzonym na potrzeby nauki rebase'a.

Czym jest rebase?

Jest to bardzo powszechna komenda, którą wykonujemy za każdym razem, kiedy oddajemy branch do code review. Rebase do mastera oznacza pobranie wszystkich zmian, które pojawiły się w międzyczasie, i ustawienie naszych nowych commitów na samej górze tego stosu. W efekcie drzewo branchy będzie wyglądało tak, jakby wszystkie Twoje zmiany na obecnym branchu były wykonane po ostatniej zmianie na masterze (lub innym branchu, do którego się rebase'ujesz).

Bardzo dobrze ilustruje to poniższy graf z dokumentacji Atlassiana:

image

Jak widzisz, commity na branchu Feature były wykonywane równolegle do zmian na masterze. Za pomocą rebase'a te zmiany zostały przeniesione tak, aby następowały po najnowszym commicie na masterze.

Pamiętaj, że rebase nie łączy branchów, a więc nie zastępuje komendy git merge. Sprawia jednak, że drzewo brancha master jest znacznie uproszczone, a commity merge'a nie zawierają w sobie zmian wynikających z rozwiązywania konfliktów. Dzięki temu łatwiej będzie znaleźć się w historii commitów.

Po wykonaniu rebase'a można już zrobić merge (lub Pull Request) i nie powinno być żadnych konfliktów, ponieważ zostaną one rozwiązane na etapie rebase'a.

Jak wykonać rebase do mastera?

W ten sposób sprawdzamy przed pull requestem, czy nasz kod nie powoduje konfliktów w kodzie.

git pull --rebase origin master

Może pojawić się sytuacja, że dojdzie do konfliktów i proces rebase’u się zatrzyma. W takim wypadku należy rozwiązywać po kolei konflikty. O rozwiązywaniu konfliktów mówiliśmy sobie przy okazji modułu o Gicie. I co paczkę konfliktów dodawać je za pomocą:

git add [SCIEZKA_DO_PLIKU_Z_KONFLIKTEM]

a następnie kontynuować proces.

git rebase --continue

Pamiętaj, że w razie jakichkolwiek problemów z rozwiązywaniem konfliktów możesz zatrzymać proces rebase’u bez krzywdy dla kodu, korzystając z polecenia:

git rebase --abort

Łączenie commitów

Ile to razy zdarzyła się sytuacja, kiedy napisaliśmy kod i zorientowaliśmy się, że popełniliśmy literówkę? Robienie kolejnego commita o nazwie "Fix typo" lub "Format code" sprawi, że nasze repozytorium urośnie do niesamowitych rozmiarów. Idąc za założeniem, że każdy commit rozwiązuje całkowicie jeden problem lub dodaje nową funkcjonalność, powinniśmy unikać nadmiarowych commitów.

Dlatego pokażemy Ci, w jaki sposób można łączyć commity w jeden. Do tego celu użyjemy tzw. rebase’u brancha w formie interaktywnej. Aby go uruchomić, wprowadzamy następującą komendę:

git rebase -i HEAD~3

Gdzie 3 jest liczbą oznaczającą liczbę commitów, które chcemy wyświetlić w interaktywnej formie. Tutaj otworzy nam się konsolowy edytor.

Zmiana domyślnego edytora

UWAGA! W większości systemów dostępny jest edytor konsolowy VIM. Jego obsługa jest bardzo specyficzna, więc przypomnij sobie wcześniej jak go obsługiwać.

Możesz też zmienić domyślny edytor na dowolny inny, nawet graficzny (tzn. nie-konsolowy).

Po otwarciu terminala pojawi nam się mniej więcej taki dokument:

pick f4b0988 Add header and footer
pick 3f91f8b Add new content
pick 6d9c1c8 Fix typo

# Rebase 7c044ca..6d9c1c8 onto 7c044ca (3 commands)
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
# d, drop = remove commit
...

Wszystko, co zaczyna się znaczkiem #, jest komentarzem. Na górze znajduje się lista commitów w kolejności od najstarszego do najnowszego. Składa się ona z 3 elementów - komenda, hasha i nazwy commita.

Poniżej listy znajduje się legenda objaśniająca wszystkie dostępne komendy. Jeśli chcielibyśmy połączyć commit Fix typo z commitem Add new content, należy dokonać takiej zmiany:

pick f4b0988 Add header and footer
pick 3f91f8b Add new content
fixup 6d9c1c8 Fix typo
...

A następnie zamknąć edytor.

Jeśli nie wystąpią żadne błędy, to powinniśmy zobaczyć, że nasza praca zakończyła się sukcesem. Po wykonaniu komendy git tree zobaczysz, że commit Fix typo "zniknął", a hash commita Add new content jest inny niż wcześniej.

UWAGA!! - Uważaj dokładnie na to, co zmieniasz i na co.

I tutaj następuje najważniejsza rzecz — jeśli wcześniej już dokonano wypchnięcia zmian do zdalnego repozytorium, to przy próbie ich wypchnięcia może pojawić się błąd. Wynika on z faktu, że zdalne repozytorium widzi konflikt w kolejności i hashach commitów pomiędzy tym, co już ma, a w tym, co ma dostać. Jeśli dokonujemy zmian za pomocą interaktywnego rebase’owania, to należy wypychać dane, używając polecenia git push -f, gdzie flaga -f oznacza --force, czyli wymuszenie.

Pamiętaj jednak, że nigdy nie używamy --force na masterze! Korzystaj z niego (bardzo ostrożnie) tylko na swoich branchach, stworzonych na potrzebę rozwiązania konkretnych problemów.

Kopiowanie commitów - CherryPick

Czasem tak się zdarza, że pracujemy na kilku branchach i chcielibyśmy skopiować jeden i tylko jeden commit do drugiego brancha, bez merge'owania wszystkich zmian na tym branchu. Jak to zrobić? Od tego jest komenda cherry-pick.

  1. Po pierwsze musimy upewnić się, że oba branche dostępne są lokalnie.
  2. Następnie wycheckoutować się na branch, z którego chcemy wziąć commit.
  3. Za pomocą funkcji git log lub git tree kopiujemy hash commita, który chcemy skopiować.
  4. Checkoutujemy się na docelowy branch, gdzie commit ma być wkopiowany.
  5. CherryPickujemy za pomocą polecenia git cherry-pick HASH_COMMITA.

Możemy sprawdzić za pomocą git log lub git tree czy zmiany się wprowadziły.

;